<?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: Hoelee</title>
    <description>The latest articles on DEV Community by Hoelee (@hoelee).</description>
    <link>https://dev.to/hoelee</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4190165%2Fa89ac5f2-a532-49d4-912e-5396b9dcadf2.png</url>
      <title>DEV Community: Hoelee</title>
      <link>https://dev.to/hoelee</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hoelee"/>
    <language>en</language>
    <item>
      <title>Why I Still Bought a GPU to Run AI When Claude and GPT Are Obviously Stronger</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sat, 14 Nov 2026 04:25:40 +0000</pubDate>
      <link>https://dev.to/hoelee/why-i-still-bought-a-gpu-to-run-ai-when-claude-and-gpt-are-obviously-stronger-17ei</link>
      <guid>https://dev.to/hoelee/why-i-still-bought-a-gpu-to-run-ai-when-claude-and-gpt-are-obviously-stronger-17ei</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/why-i-still-bought-a-local-gpu/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Across the self-hosted production infrastructure I manage — Docker stacks, NAS&lt;br&gt;
storage, tunnels, and automated cron pipelines — I still pay for frontier models.&lt;/p&gt;

&lt;p&gt;So when people find out I run local models too, the question is always the same:&lt;br&gt;
&lt;em&gt;Claude and GPT are stronger. Local models are a pain to set up, slower, and&lt;br&gt;
often just worse. So why did you spend money on a GPU?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you are only comparing raw model capability, I agree with you completely.&lt;br&gt;
When I hit a genuinely new problem, do complex reasoning, plan a product&lt;br&gt;
architecture, or kick off a long agent run, I still reach for Claude or GPT&lt;br&gt;
first. I did not deploy local models because I think they have beaten the&lt;br&gt;
closed frontier.&lt;/p&gt;

&lt;p&gt;I did it because of a different problem that only showed up &lt;em&gt;after&lt;/em&gt; AI got good&lt;br&gt;
enough to depend on. Everything I built started sitting on a service I do not&lt;br&gt;
control.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem is not capability — it is dependency
&lt;/h2&gt;

&lt;p&gt;How many tokens you get, what a plan costs, which regions can buy the commercial&lt;br&gt;
service, when the model version changes under you — none of that is the user's&lt;br&gt;
decision. Anthropic has regional restrictions of its own; some markets cannot&lt;br&gt;
buy Claude's commercial service directly. OpenAI's plans, quotas and model&lt;br&gt;
lineup keep shifting. These are commercial services, so of course they change.&lt;/p&gt;

&lt;p&gt;That was fine when ChatGPT was something I opened occasionally to ask a&lt;br&gt;
question. If it was unavailable today, I would ask tomorrow and lose nothing.&lt;/p&gt;

&lt;p&gt;It stopped being fine once I started using AI to write code, run agents,&lt;br&gt;
analyze documents, handle support, organize company data, and wire models into&lt;br&gt;
workflows that run every day. When AI moves from &lt;em&gt;chat tool&lt;/em&gt; to &lt;em&gt;infrastructure&lt;/em&gt;,&lt;br&gt;
the question changes. It is no longer "which model is smarter?" It becomes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If I depend on this model today, and its terms change tomorrow, does my work&lt;br&gt;
still run?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is why I think the stronger the closed models get, the more valuable a&lt;br&gt;
local model becomes. Precisely &lt;em&gt;because&lt;/em&gt; they are so good, people hand them more&lt;br&gt;
and more work. And the deeper the dependency, the more of your operation someone&lt;br&gt;
else controls.&lt;/p&gt;

&lt;p&gt;There is a second-order version of this that rarely gets said plainly. Every big&lt;br&gt;
lab talks about AI safety, model risk, and restricting certain content, regions&lt;br&gt;
or use cases — while all of them keep racing to make models stronger, because&lt;br&gt;
capability &lt;em&gt;is&lt;/em&gt; the competitive advantage. Nobody stops, because if you stop,&lt;br&gt;
someone else does not.&lt;/p&gt;

&lt;p&gt;So what actually gets controlled is rarely whether AI keeps developing. It is&lt;br&gt;
who may use it, how, how much, and how far the vendor is willing to go. For an&lt;br&gt;
individual, that reads like a terms-of-service page. For a business, it is&lt;br&gt;
&lt;strong&gt;supplier risk&lt;/strong&gt;. If your core process and every agent, tool and data pipeline&lt;br&gt;
around it are pinned to one vendor, you are not just buying tokens — you are&lt;br&gt;
handing over part of the steering wheel.&lt;/p&gt;




&lt;h2&gt;
  
  
  "But local models are worse" — yes, and that is the wrong comparison
&lt;/h2&gt;

&lt;p&gt;This is the most honest objection, and it is true. Put a model that fits on a&lt;br&gt;
24GB or 32GB consumer card against Claude Opus and it loses on complex tasks. No&lt;br&gt;
argument.&lt;/p&gt;

&lt;p&gt;But the comparison most people run is wrong. The question is not &lt;em&gt;can Qwen beat&lt;br&gt;
Claude&lt;/em&gt;. It is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Is the capability gap large enough to justify using the most expensive model&lt;br&gt;
for this task, forever?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because the trade is not symmetric. A 20–30B model is meaningfully behind the&lt;br&gt;
frontier — and costs one to two orders of magnitude less per token, or runs on&lt;br&gt;
hardware you already own. Different ratio, different decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  I treat capability as a threshold, not a ranking
&lt;/h3&gt;

&lt;p&gt;If a task is below some capability line, it cannot be done at all — use the&lt;br&gt;
strong model. But once a model crosses that line, the things that decide how I&lt;br&gt;
actually work stop being benchmark scores and become cost, speed, whether it can&lt;br&gt;
run for hours, and whether I control the system.&lt;/p&gt;

&lt;p&gt;Concretely, here is how it splits for me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;What I reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vague problem, no clear direction yet&lt;/td&gt;
&lt;td&gt;Frontier model — a wrong early call invalidates everything downstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product architecture, data model, acceptance criteria&lt;/td&gt;
&lt;td&gt;Frontier model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scaffolding a project to a fixed spec, editing files, running tests, fixing failures&lt;/td&gt;
&lt;td&gt;Local model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-running agents, retries, batch jobs, bulk content&lt;/td&gt;
&lt;td&gt;Local model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The important shift is the second block. Once direction is settled and the agent&lt;br&gt;
is &lt;em&gt;executing a spec&lt;/em&gt;, a single task may call a model 20–50 times. Do all fifty&lt;br&gt;
calls need the most expensive model on the market? Usually not. The agent is not&lt;br&gt;
deciding what the product should be — it is following specifications that are&lt;br&gt;
already written. If a local model understands those specs and knows how to use&lt;br&gt;
the tools, it has crossed the working threshold.&lt;/p&gt;




&lt;h2&gt;
  
  
  Agents turn a small price difference into a large one
&lt;/h2&gt;

&lt;p&gt;With chat, you ask once and get one answer. If Claude costs many times more than&lt;br&gt;
a local model, you barely notice.&lt;/p&gt;

&lt;p&gt;Agents are not one call. A task can invoke the model twenty times, fifty times;&lt;br&gt;
it fails and reruns. The per-token price gap gets multiplied by every call.&lt;/p&gt;

&lt;p&gt;And anyone who has actually built these pipelines knows the expensive part is&lt;br&gt;
not the final run — it is the testing before it. You rewrite the prompt, swap&lt;br&gt;
tooling, re-chunk the data, adjust the workflow, and run the same thing ten or&lt;br&gt;
twenty times. That is normal.&lt;/p&gt;

&lt;p&gt;On metered API calls, I feel it: &lt;em&gt;I am burning money right now.&lt;/em&gt; On my own card,&lt;br&gt;
there is still electricity and hardware cost, but running an agent five times or&lt;br&gt;
fifty times does not add a line to an invoice. That difference changes my&lt;br&gt;
behavior — it decides whether I dare keep testing. Most stable AI workflows are&lt;br&gt;
not written correctly the first time. They get there after failing enough times&lt;br&gt;
that you find out where they break.&lt;/p&gt;

&lt;p&gt;The value of local compute is that you move that trial-and-error into a fixed&lt;br&gt;
cost. That is a different question from "how much does a million tokens cost."&lt;/p&gt;

&lt;p&gt;Here is the math on a real engineering workload: a typical agent loop with tool-use iterating through tests and refactoring takes 30–50 turns. Because each turn carries previous tool outputs and conversation history, an average turn easily loads 15k–25k tokens of context — consuming 600k to 1.2M tokens in a single agent run. If you are tuning a new autonomous workflow and test it 20 times a day, you burn 15M to 24M tokens. On frontier APIs like Claude 3.5 Sonnet ($3/$15 per million tokens), that single day of debugging costs $50–$150; on Opus, it crosses several hundred dollars.&lt;/p&gt;

&lt;p&gt;On my own self-hosted GPU node (a dedicated 12 GB VRAM card running quantized Qwen 2.5 via LM Studio / llama.cpp), the entire rig pulls under 200W at full load. Running 20 iterations or 100 iterations adds cents in electricity and $0 in marginal API bills. The real math is not just "cost per million tokens" — it is whether you can afford to let an agent fail enough times to become reliable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Open models crossed the useful line, and the line keeps moving
&lt;/h2&gt;

&lt;p&gt;Three years ago, running a model on a consumer card that could genuinely do work&lt;br&gt;
for you was a research project. Today, 20–30B models — quantized — handle real&lt;br&gt;
work on 24GB and 32GB cards. Go up in VRAM and language, image, and now portions&lt;br&gt;
of video generation become self-hostable too.&lt;/p&gt;

&lt;p&gt;The important part is not "open source finally won." It is that work which used&lt;br&gt;
to be closed-model-only keeps falling inside the range that local hardware can&lt;br&gt;
do acceptably. If three years ago two of ten jobs could run locally, and today&lt;br&gt;
four or five can, that ratio alone has changed the value of owning the hardware.&lt;/p&gt;

&lt;p&gt;And when I buy a GPU, I am not buying a specific model. Models change. If a&lt;br&gt;
better coding model ships next month, I swap weights. If a better quantization&lt;br&gt;
scheme lands in six months, the same card suddenly fits a model it could not run&lt;br&gt;
before. What I actually bought is &lt;strong&gt;compute I control&lt;/strong&gt;; the models on top of it&lt;br&gt;
are disposable.&lt;/p&gt;

&lt;p&gt;That is the structural difference. A closed model is stronger — and also exactly&lt;br&gt;
the version one company currently chooses to offer you, at terms it currently&lt;br&gt;
chooses. Local hardware is weaker — but as long as the card holds up, I decide&lt;br&gt;
which model runs today, which version I switch to, and how long it runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fixed compute vs. renting compute
&lt;/h2&gt;

&lt;p&gt;If AI stays a question-and-answer tool, I do not think most people need a GPU at&lt;br&gt;
all. But that is clearly not the direction. Agents run for long stretches. Image&lt;br&gt;
and video models want bulk generation. Companies hand more fixed processes to&lt;br&gt;
AI. We are not just &lt;em&gt;using models more often&lt;/em&gt; — models are becoming a continuously&lt;br&gt;
working compute unit.&lt;/p&gt;

&lt;p&gt;At that point, owning part of the capacity and renting every execution become&lt;br&gt;
&lt;strong&gt;two different cost structures&lt;/strong&gt;. Low usage: renting is obviously more&lt;br&gt;
convenient. High, steady usage: owning lets you control and predict cost.&lt;/p&gt;

&lt;p&gt;It also buys something no API can sell you — control. When the machine is mine,&lt;br&gt;
I can point it at internal data, connect my own tools, and run agents for hours&lt;br&gt;
without checking whether a vendor's plan, quota or limits changed overnight.&lt;br&gt;
Once AI is inside real company processes, this stops being about saving tokens.&lt;br&gt;
Some data should not leave the environment it lives in. Some agent permissions&lt;br&gt;
should not be handed wholesale to an external service. If the model runs on my&lt;br&gt;
own hardware, data, tools, permissions and execution can be designed together.&lt;br&gt;
&lt;em&gt;That&lt;/em&gt; is what I mean by control — not merely having the weights on disk.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I actually evaluate now
&lt;/h2&gt;

&lt;p&gt;I no longer judge a local model by "it is dumber than Claude, therefore&lt;br&gt;
worthless." Four questions, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does this model clear my working threshold&lt;/strong&gt; for this specific job?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How many times will this task execute?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does it need heavy trial-and-error, or long agent runs?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the data or system need to stay in my own environment?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When those stack up, a local model — even one that is not the strongest — can be&lt;br&gt;
the better tool for that slice of work. And as open models improve, that slice&lt;br&gt;
keeps getting bigger.&lt;/p&gt;

&lt;p&gt;So what I am bullish on is not one model or one GPU. It is the trend: &lt;strong&gt;more AI&lt;br&gt;
work becomes doable on hardware you own.&lt;/strong&gt; A consumer card that used to run only&lt;br&gt;
language models now runs agents, image generation, video. Better models and&lt;br&gt;
better inference frameworks keep giving the same metal new jobs.&lt;/p&gt;

&lt;p&gt;Hardware depreciates, new cards get more expensive — that is real. But the model&lt;br&gt;
is not locked in. As long as the machine has enough compute, I can keep swapping&lt;br&gt;
in newer open models.&lt;/p&gt;




&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;I no longer compare "which model is smarter." I compare &lt;strong&gt;how much capability&lt;br&gt;
this job needs, how many times it will run, and how much control I am willing to&lt;br&gt;
leave in someone else's hands.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Where I need frontier reasoning, I pay for the frontier. But when work becomes&lt;br&gt;
high-volume, continuous and predictable — and a local model has crossed the&lt;br&gt;
usable threshold — there is no reason to keep paying frontier prices for every&lt;br&gt;
single step.&lt;/p&gt;

&lt;p&gt;In one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Use the frontier model to decide what to build. Use local compute to build it&lt;br&gt;
a thousand times.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is why I bought the GPU.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>python</category>
      <category>linux</category>
    </item>
    <item>
      <title>The Five-Step AI Roadmap Everyone Shares — and What I Actually Run in Production</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Fri, 13 Nov 2026 04:26:19 +0000</pubDate>
      <link>https://dev.to/hoelee/the-five-step-ai-roadmap-everyone-shares-and-what-i-actually-run-in-production-hml</link>
      <guid>https://dev.to/hoelee/the-five-step-ai-roadmap-everyone-shares-and-what-i-actually-run-in-production-hml</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/five-step-ai-roadmap-what-i-actually-run/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a graphic that makes the rounds every few weeks, and by now it has&lt;br&gt;
reached my feed in translated form, reposted by accounts I don't follow. It&lt;br&gt;
lays out the path to working with large models in five steps: learn the&lt;br&gt;
fundamentals — Transformer, neural networks, attention — and stop as soon as&lt;br&gt;
you can read the diagrams; learn prompt engineering, which takes days; learn&lt;br&gt;
RAG so the model answers from your own data instead of making things up;&lt;br&gt;
learn agents, the biggest opportunity in the field; and then learn&lt;br&gt;
fine-tuning, where the advice is to skip pretraining entirely (that belongs&lt;br&gt;
to the big labs) and get comfortable with multimodal fine-tuning instead.&lt;br&gt;
There is a salary band attached to each step, and the band at the end is the&lt;br&gt;
reason the thing spreads.&lt;/p&gt;

&lt;p&gt;I am not going to argue with the list. It is not wrong — it is a syllabus.&lt;br&gt;
And a syllabus is the kind of advice that behaves perfectly in a classroom&lt;br&gt;
and then dies on contact with a system, because it tells you what to learn&lt;br&gt;
and in what order, and never tells you which of those items you will still&lt;br&gt;
be doing years later when somebody is paying you for the result. I have been&lt;br&gt;
running AI workloads of my own for a while now — not research, not model&lt;br&gt;
training, but real services other people depend on. So here is the honest&lt;br&gt;
version of that graphic, checked against what is actually running on my&lt;br&gt;
hardware tonight.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;The reason to distrust the roadmap is not that the items are wrong. It is&lt;br&gt;
that a roadmap tells you what to &lt;em&gt;study&lt;/em&gt;, while employment asks what you can&lt;br&gt;
&lt;em&gt;operate&lt;/em&gt;. Those are different skill sets, and the gap between them is where&lt;br&gt;
most people who "learned AI in five steps" stall: they can describe a RAG&lt;br&gt;
pipeline, but they have never had one break on them at 11pm in a way that&lt;br&gt;
made them understand retrieval, cost, ingestion and evaluation in a single&lt;br&gt;
evening.&lt;/p&gt;

&lt;p&gt;The rest of this post maps each step to something I actually run, states&lt;br&gt;
plainly which steps I have never done, and gives you the order I would pick&lt;br&gt;
if I had to start again. Every claim below is measured on a dedicated&lt;br&gt;
workstation with an RTX 3060 (12 GB) and a private NAS node.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: Fundamentals — enough to be dangerous, then stop
&lt;/h2&gt;

&lt;p&gt;The graphic's framing here is closer to right than most engineers admit:&lt;br&gt;
learn the Transformer until the diagrams make sense, then move on. My&lt;br&gt;
version stopped earlier than that. I can hold a real conversation about&lt;br&gt;
attention, tokens, context windows, quantization schemes and the KV cache,&lt;br&gt;
because those are the levers I actually turn when a model has to fit into 12&lt;br&gt;
GB of VRAM and answer fast enough to be useful in a chat window. I could not&lt;br&gt;
derive backpropagation on a whiteboard, and I would not trust a number I&lt;br&gt;
produced from memory.&lt;/p&gt;

&lt;p&gt;What that costs me: nothing measurable so far, in systems that ship. What it&lt;br&gt;
would cost me: the day I train something. If your goal is "own the AI stack&lt;br&gt;
for a small business", step 1 is a reading list, not a degree — and the&lt;br&gt;
honest reason to buy local hardware in the first place is not that a 12 GB&lt;br&gt;
card beats the frontier models, because it does not; it is what running a&lt;br&gt;
model yourself teaches you that an API never will. I wrote about that trade&lt;br&gt;
honestly in&lt;br&gt;
&lt;a href="https://blog.hoelee.com/posts/why-i-still-bought-a-local-gpu/" rel="noopener noreferrer"&gt;why I still bought a local GPU to run AI&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 2: Prompt engineering — the step with the lowest ceiling
&lt;/h2&gt;

&lt;p&gt;"Days to learn" is accurate, and that is the problem with it. Prompting is&lt;br&gt;
the cheapest step to enter and the first one to get automated, so it is&lt;br&gt;
where enormous numbers of people stop and start describing themselves as AI&lt;br&gt;
engineers. The honest skill is not writing a better instruction; it is&lt;br&gt;
deciding when an instruction should not exist at all.&lt;/p&gt;

&lt;p&gt;If a task has one correct answer, a deterministic input and a known output&lt;br&gt;
shape, then a script is faster, cheaper and cannot hallucinate. I have&lt;br&gt;
replaced agent prompts with forty lines of Python more than once, and&lt;br&gt;
&lt;a href="https://blog.hoelee.com/posts/stop-writing-agent-prompts-for-deterministic-work/" rel="noopener noreferrer"&gt;wrote up the case for that&lt;/a&gt;.&lt;br&gt;
What survives on the prompt side is interface design: what the model is&lt;br&gt;
allowed to see, what shape its answer must take, how much of your own&lt;br&gt;
context you are willing to hand it, and — the part the graphic never&lt;br&gt;
mentions — how you would know the output got worse. That last item is&lt;br&gt;
evaluation, and in my experience it is the difference between a demo and a&lt;br&gt;
service.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: RAG — the hard half is everything except the retrieval
&lt;/h2&gt;

&lt;p&gt;The one-line version everyone quotes is "give the model your data so it&lt;br&gt;
stops lying". I run exactly that: a self-hosted memory layer built from&lt;br&gt;
mem0, LiteLLM and PostgreSQL 17 with pgvector, all on my own NAS, with&lt;br&gt;
memories scoped to a &lt;code&gt;user_id&lt;/code&gt; so one customer's history cannot surface in&lt;br&gt;
another's answer —&lt;br&gt;
&lt;a href="https://blog.hoelee.com/posts/self-hosting-mem0-memory-stack/" rel="noopener noreferrer"&gt;the whole stack is written up here&lt;/a&gt;,&lt;br&gt;
including the container layout and the fact that the API is two small HTTP&lt;br&gt;
calls.&lt;/p&gt;

&lt;p&gt;Three things the tutorial version of RAG skips, and each of them cost me&lt;br&gt;
real time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingestion is the boring half that breaks.&lt;/strong&gt; Retrieval quality gets all
the attention; getting the data in reliably is where the work is. The
most recent input I pushed through my own speech-to-text stack was a
57-second video sitting on my desktop — it came back as text in about
eight seconds on the same 12 GB card that already serves live dictation
for my phone and laptop. That speed is what makes the stack usable
instead of a demo, and the pipeline is
&lt;a href="https://blog.hoelee.com/posts/self-hosted-speech-to-text-api/" rel="noopener noreferrer"&gt;documented end to end here&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Byte-level mistakes look like model mistakes.&lt;/strong&gt; My first attempt to
feed that same service through a workflow tool reported a clean 502
"conversion failed" from the audio layer. The real cause was a multipart
upload arriving without a &lt;code&gt;Content-Type&lt;/code&gt; header, which made the broker
decode raw audio as UTF-8 text and destroy it before any of my code ran.
Nothing about that bug is AI. It is exactly the kind of failure the
five-step graphic cannot prepare you for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval is not a model problem.&lt;/strong&gt; When an answer is wrong, the useful
question is whether the right chunk was ever retrieved, whether it
survived chunking, and whether the model was given room to use it.
Swapping to a bigger model has never once fixed a retrieval bug for me.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Underneath that entire layer there are two calls, which is worth seeing&lt;br&gt;
once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST http://127.0.0.1:20015/memories    # store a fact or a conversation, tagged by user_id
GET  http://127.0.0.1:20015/search      # semantic search, scoped to the same user_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing exotic — which is the point. A memory layer you can describe in two&lt;br&gt;
lines is a memory layer you can debug, and the whole thing stays local.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Agents — the opportunity is real, and the agent is the smallest part
&lt;/h2&gt;

&lt;p&gt;I agree with the graphic's ranking: this is where the work is. I disagree&lt;br&gt;
with what it implies by the word "agent", because an agent is a model plus a&lt;br&gt;
loop plus tools — and the part that decides whether it survives contact with&lt;br&gt;
reality is the checking around it, not the model at the centre. I've written&lt;br&gt;
about this as&lt;br&gt;
&lt;a href="https://blog.hoelee.com/posts/loop-engineering-without-a-coding-agent/" rel="noopener noreferrer"&gt;loop engineering&lt;/a&gt;,&lt;br&gt;
and the finding I keep coming back to is uncomfortable: most of my scheduled&lt;br&gt;
automation has no AI in it at all, and those are precisely the jobs that&lt;br&gt;
have never needed babysitting. The model is the part that adds judgement.&lt;br&gt;
The rest of the system is what makes the judgement safe to trust.&lt;/p&gt;

&lt;p&gt;Three things that are actually running here, so the claim is concrete rather&lt;br&gt;
than aspirational:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Telegram support bot for my own digital-goods shop that keeps long-term
memory per customer, so a returning buyer is not a stranger on their
second message. The memory layer it writes into is the same self-hosted
mem0 stack from step 3.&lt;/li&gt;
&lt;li&gt;A scheduled job that manages discount settings in my Shopee seller
account, which is the least glamorous useful agent I own: it reads the
current state, decides, acts, and reports — and the entire value comes
from the fact that it checks the result afterwards instead of assuming its
click landed.&lt;/li&gt;
&lt;li&gt;A research run that compared twenty VPS providers in parallel by fanning
the work out across subagents, which is a pattern worth learning even if
you never build a "chatbot":
&lt;a href="https://blog.hoelee.com/posts/how-i-vetted-20-vps-providers-with-parallel-subagents/" rel="noopener noreferrer"&gt;the write-up is here&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Fine-tuning — the step I have never done, on purpose
&lt;/h2&gt;

&lt;p&gt;The graphic says skip pretraining, which is correct, and then suggests&lt;br&gt;
learning multimodal fine-tuning. For anyone whose job looks like mine, I'd&lt;br&gt;
push back on the second half.&lt;/p&gt;

&lt;p&gt;I have never fine-tuned a model. Not because I couldn't, but because in the&lt;br&gt;
time I have been running these services, none of my failures have been&lt;br&gt;
model-behaviour failures. They were retrieval failures, licensing surprises,&lt;br&gt;
byte-level failures and operational failures — a job that reported success&lt;br&gt;
while the file it claimed to replace was untouched, a mount whose metadata&lt;br&gt;
lied about which file it had swapped, an API whose real model name differed&lt;br&gt;
from the one it advertised. A LoRA does not fix a schema drift.&lt;/p&gt;

&lt;p&gt;Where I would fine-tune: one narrow, well-defined task; thousands of&lt;br&gt;
labelled examples rather than dozens; an inference bill or a latency ceiling&lt;br&gt;
that a smaller tuned model would genuinely fix; and the willingness to own&lt;br&gt;
an evaluation set, because a fine-tune you cannot measure is a regression&lt;br&gt;
you cannot see. And I would rent the GPU for it long before I bought a&lt;br&gt;
second card — the training run is a project, not a workstation purchase.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I were starting from zero with that graphic in front of me, I would not&lt;br&gt;
walk it in numerical order. I would walk it in the order my problems&lt;br&gt;
arrived, and accept that the first real lesson will not come from a course:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treat step 1 as a reading list you finish in public documentation, not a
curriculum. Enough theory to know why quantization and context limits
hurt; no more until something forces you deeper.&lt;/li&gt;
&lt;li&gt;Spend a week on step 2, then immediately automate the parts of it that
have one correct answer, and move the rest of your attention to evaluation
— which the graphic omits entirely.&lt;/li&gt;
&lt;li&gt;Live in steps 3 and 4, because that is where nearly all employed AI work
currently sits: wiring models to real data, and making the loop around
them trustworthy.&lt;/li&gt;
&lt;li&gt;Keep step 5 in your back pocket until a specific problem names it.
Fine-tuning is a technique with a precondition, not a rung on a ladder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And notice the roadmap's real omission, which is not a step but half the&lt;br&gt;
job: identity, storage, deployment, cost, monitoring, and who gets woken up&lt;br&gt;
when it breaks. That is the half that decides whether a team can use you,&lt;br&gt;
and it is the half this blog mostly writes about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Three of those five steps run in production on one desktop GPU and one NAS,&lt;br&gt;
and the honest scorecard is this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A 57-second video becomes text in about eight seconds, on the same 12 GB
card that serves live dictation — and the speech server drops from &lt;strong&gt;4,906
MiB to 1,321 MiB&lt;/strong&gt; of VRAM when I toggle it idle, because a GPU that is
always resident is a GPU I cannot use for anything else.&lt;/li&gt;
&lt;li&gt;The memory layer answers in two small HTTP calls, scoped per user, backed
by PostgreSQL with pgvector on the NAS — mine, not a vendor's.&lt;/li&gt;
&lt;li&gt;Most of the scheduled automation that keeps this stack alive contains no
model at all, and it is the most reliable part of the system.&lt;/li&gt;
&lt;li&gt;Step 5 stays empty, and I would rather say that plainly than pad a
roadmap. The gap is real, and it is a choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the five-step graphic is the syllabus, this is the transcript. The&lt;br&gt;
syllabus tells you what to learn. The transcript is what a recruiter or a&lt;br&gt;
client can actually check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want an AI stack that runs in your business?
&lt;/h2&gt;

&lt;p&gt;I run Hoelee Enterprise, a website design and development business for&lt;br&gt;
Malaysian SMEs — and the AI work above runs on the same infrastructure I&lt;br&gt;
build for clients. If you want a model answering from &lt;em&gt;your&lt;/em&gt; data, on &lt;em&gt;your&lt;/em&gt;&lt;br&gt;
hardware, without a per-seat subscription attached, I'd be glad to talk:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📱 &lt;strong&gt;WhatsApp:&lt;/strong&gt; &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📧 &lt;strong&gt;Email:&lt;/strong&gt;
&lt;a href="mailto:me@hoelee.com?subject=AI%20stack%20for%20my%20business"&gt;me@hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;Website:&lt;/strong&gt; &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And if you are hiring for a platform, DevOps or AI-infrastructure role, the&lt;br&gt;
same channels reach me.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>selfhosted</category>
      <category>automation</category>
    </item>
    <item>
      <title>Your Own Speech-to-Text Server: Faster Than Typing, Private by Default</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Thu, 12 Nov 2026 04:25:51 +0000</pubDate>
      <link>https://dev.to/hoelee/your-own-speech-to-text-server-faster-than-typing-private-by-default-5g58</link>
      <guid>https://dev.to/hoelee/your-own-speech-to-text-server-faster-than-typing-private-by-default-5g58</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/self-hosted-speech-to-text-api/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Typing is the slowest thing most offices do all day. An average person types&lt;br&gt;
40 words per minute; they &lt;em&gt;speak&lt;/em&gt; 130. Every email, quotation, report, support&lt;br&gt;
reply and chat message in your business pays that tax — and the tax is usually&lt;br&gt;
paid by whoever is fastest at the keyboard.&lt;/p&gt;

&lt;p&gt;Speech-to-text removes it. But the version most people adopt has two problems:&lt;br&gt;
it costs a monthly subscription per seat, and it ships your voice — your&lt;br&gt;
internal discussions, customer names, pricing, contracts — to someone else's&lt;br&gt;
cloud.&lt;/p&gt;

&lt;p&gt;So I built the other version: &lt;strong&gt;one speech-to-text API running on a single&lt;br&gt;
desktop PC in my office, with my own GPU doing the work.&lt;/strong&gt; My development PC,&lt;br&gt;
my iPhone, an iPad and Android phones all dictate through it. It's faster than&lt;br&gt;
typing, it's unlimited, and nothing leaves my network.&lt;/p&gt;

&lt;p&gt;This is how it works — including the four traps that cost me most of a day.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Speech-to-text is the highest-leverage office automation that isn't an AI&lt;br&gt;
chatbot. Concretely, what a self-hosted setup buys you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Over 3x the throughput of typing (65%–70% time reduction).&lt;/strong&gt; At 130–150 wpm spoken
versus ~40 wpm typed, dictating a 500-word email is about 3.5 minutes of talking
instead of 12 minutes of typing. Someone who writes 10 emails a day gets over an hour back —
every day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost scales with hardware, not headcount.&lt;/strong&gt; Cloud dictation is priced per
user per month, forever. This one runs on hardware you own. Add the tenth
employee and the marginal cost is zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unlimited length, no quota anxiety.&lt;/strong&gt; No minute caps, no "you've reached
your monthly limit" at 4pm on a Friday.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your audio stays yours.&lt;/strong&gt; Medical notes, legal drafts, HR conversations,
customer pricing — voice is sensitive by default. Self-hosted means the
transcription never leaves the building.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It works in whatever app already has focus.&lt;/strong&gt; Not a separate transcribe-then-
paste website — a keyboard you use inside Outlook, WhatsApp Web, your CRM,
or your own internal tools.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run an office where people write all day, this is the same class of win&lt;br&gt;
as moving from dial-up to broadband, and it costs a GPU you may already own.&lt;/p&gt;
&lt;h2&gt;
  
  
  What you need
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;What I used&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPU machine&lt;/td&gt;
&lt;td&gt;Desktop PC with an RTX 3060 (12 GB)&lt;/td&gt;
&lt;td&gt;Any NVIDIA card with ≥6 GB VRAM works; it can be a normal work PC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper build&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;whisper.cpp&lt;/code&gt; with CUDA&lt;/td&gt;
&lt;td&gt;Free, open source&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;large-v3&lt;/code&gt; (~2.9 GB)&lt;/td&gt;
&lt;td&gt;Best accuracy; smaller models use less VRAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery layer&lt;/td&gt;
&lt;td&gt;n8n + nginx gateway&lt;/td&gt;
&lt;td&gt;Adds auth, so the endpoint can be shared safely&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clients&lt;/td&gt;
&lt;td&gt;Desktop app, iPhone, iPad, Android&lt;/td&gt;
&lt;td&gt;Any client that speaks OpenAI's transcription API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The PC doesn't have to be dedicated. Mine also runs development work,&lt;br&gt;
local LLM inference and image generation — the model loads when a request&lt;br&gt;
arrives and unloads when idle, so it isn't permanently holding VRAM.&lt;/p&gt;
&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;The important design decision is that &lt;strong&gt;the phones never talk to the GPU&lt;br&gt;
directly.&lt;/strong&gt; There's a gatekeeper in between that handles authentication,&lt;br&gt;
so the GPU itself stays on a private network.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   iPhone / iPad / Android / Work PCs
                 │
                 │  HTTPS + per-device API key
                 ▼
     ┌───────────────────────────┐
     │  https://stt.example.com  │   public HTTPS entry
     │  reverse proxy            │
     └─────────────┬─────────────┘
                   ▼
     ┌───────────────────────────┐
     │  nginx gateway container  │   strips/forwards auth, fixed routes
     └─────────────┬─────────────┘
                   ▼
     ┌───────────────────────────┐
     │  n8n workflow             │   validates the key → 401/403 if wrong
     │  (no audio ever logged)   │
     └─────────────┬─────────────┘
                   ▼
     ┌───────────────────────────┐
     │  whisper.cpp on the PC    │   GPU transcription
     │  :20129  →  {"text": …}   │
     └───────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four layers, each doing one job: the proxy terminates TLS, the gateway fixes&lt;br&gt;
routing, n8n authorises, whisper transcribes. The result is&lt;br&gt;
&lt;code&gt;POST /v1/audio/transcriptions&lt;/code&gt; — the same shape OpenAI uses, which means any&lt;br&gt;
OpenAI-compatible client works with zero custom code.&lt;/p&gt;
&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;p&gt;Whisper needs CUDA and a CMake toolchain. On Windows that's three installs and&lt;br&gt;
a build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/ggerganov/whisper.cpp
&lt;span class="nb"&gt;cd&lt;/span&gt; whisper.cpp
cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86
cmake --build build --config Release -j
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fetch the model and run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# large-v3, ~2.9 GB&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; models &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; sh ./download-ggml-model.sh large-v3 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ..
./build/bin/Release/whisper-server.exe \
  -m models/ggml-large-v3.bin \
  --host 0.0.0.0 --port 20129 \
  --convert
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--convert&lt;/code&gt; matters more than it looks: it lets the server accept MP3 and&lt;br&gt;
other compressed formats by shelling out to ffmpeg, instead of forcing every&lt;br&gt;
client to send raw WAV. Most mobile apps send compressed audio.&lt;/p&gt;

&lt;p&gt;The server is now a working transcription API. Everything after this point&lt;br&gt;
is about making it safe to reach from a phone.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 1: CUDA Toolkit installs an incomplete compiler
&lt;/h2&gt;

&lt;p&gt;The CUDA Toolkit installer's default component selection includes &lt;code&gt;nvcc&lt;/code&gt; — but&lt;br&gt;
&lt;em&gt;not&lt;/em&gt; the pieces &lt;code&gt;nvcc&lt;/code&gt; needs to actually compile. My first two CMake runs both&lt;br&gt;
failed on missing headers, and the errors pointed at my build config rather&lt;br&gt;
than at a partial toolchain.&lt;/p&gt;

&lt;p&gt;The fix is to add three components explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;crt_13.x      → C runtime headers (the missing &amp;lt;crt/host_config.h&amp;gt;)
nvvm_13.x     → contains cicc, the actual CUDA compiler backend
cublas_13.x   → cuBLAS, required to link at runtime
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Symptom to watch for: &lt;code&gt;nvcc --version&lt;/code&gt; succeeds, but the build fails immediately&lt;br&gt;
with a missing-header error. A working &lt;code&gt;nvcc&lt;/code&gt; is not a working toolkit.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 2: it listens on localhost, so your firewall is innocent
&lt;/h2&gt;

&lt;p&gt;Whisper's default bind is &lt;code&gt;127.0.0.1&lt;/code&gt; — loopback only. Nothing else on the&lt;br&gt;
network can reach it, no matter what your firewall says.&lt;/p&gt;

&lt;p&gt;I lost real time here, because I assumed a firewall problem and tested the&lt;br&gt;
firewall repeatedly (including turning it off entirely) while the actual cause&lt;br&gt;
was the bind address. Loopback-only is &lt;em&gt;defined&lt;/em&gt; to refuse every other&lt;br&gt;
interface.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# what it looks like when the port is live but not exposed&lt;/span&gt;
netstat -an &lt;span class="o"&gt;|&lt;/span&gt; grep 20129
&lt;span class="c"&gt;#   TCP    127.0.0.1:20129    0.0.0.0:0    LISTENING     ← only you can reach it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the bind, and the picture changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;--host 0.0.0.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   TCP    0.0.0.0:20129     0.0.0.0:0    LISTENING      ← the network can reach it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check the bind before you touch the firewall.&lt;/strong&gt; The two failures present&lt;br&gt;
identically — a connection that times out — and only one of them is a firewall&lt;br&gt;
problem.&lt;/p&gt;

&lt;p&gt;One related gotcha: Windows Firewall profiles can re-enable themselves. A rule&lt;br&gt;
scoped to &lt;code&gt;Private,Domain&lt;/code&gt; will silently stop working on a network Windows has&lt;br&gt;
since reclassified as &lt;em&gt;Public&lt;/em&gt;, and an update that re-enables a profile has the&lt;br&gt;
same effect. Scope the rule to the LAN subnet rather than to a profile name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nc"&gt;New&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;NetFirewallRule&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;DisplayName&lt;/span&gt; &lt;span class="s1"&gt;'Whisper STT'&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Direction&lt;/span&gt; &lt;span class="nc"&gt;Inbound&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;Action&lt;/span&gt; &lt;span class="nc"&gt;Allow&lt;/span&gt; &lt;span class="sb"&gt;`
  -Protocol TCP -LocalPort 20129 -Profile Any -RemoteAddress LocalSubnet
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-RemoteAddress LocalSubnet&lt;/code&gt; keeps it reachable from your office while staying&lt;br&gt;
unreachable from the internet — which is the correct posture even with auth in&lt;br&gt;
front.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 3: n8n throws away your audio (twice)
&lt;/h2&gt;

&lt;p&gt;Putting n8n in the path is deliberate — it's where the API key check lives — but&lt;br&gt;
it has two behaviours that silently break a proxy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: Code nodes discard binary data.&lt;/strong&gt; My workflow was Webhook → Code&lt;br&gt;
(validate key) → HTTP Request (forward audio). The Code node returned JSON, and&lt;br&gt;
n8n's binary payload — the audio itself — never made it past it. Subsequent&lt;br&gt;
error: &lt;em&gt;"Make sure that the previous node outputs a binary file."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;n8n's Webhook node accepts multipart uploads and stores them as binary. To&lt;br&gt;
preserve that through an auth check, the Code node must pass the binary&lt;br&gt;
reference through explicitly, not just return JSON. And the key name is not&lt;br&gt;
what you'd guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────┬────────────────────────────┐
│ input field  │ binary key in n8n          │
├──────────────┼────────────────────────────┤
│ file         │ data0                      │
│ (second)     │ data1                      │
└──────────────┴────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The multipart field is named &lt;code&gt;file&lt;/code&gt;, but n8n indexes it as &lt;code&gt;data0&lt;/code&gt;. Forwarding&lt;br&gt;
&lt;code&gt;$binary.data&lt;/code&gt; fails; &lt;code&gt;$binary.data0&lt;/code&gt; works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: execution logging stores the audio.&lt;/strong&gt; By default n8n persists&lt;br&gt;
execution data — including binary payloads — which means every dictation is&lt;br&gt;
written to the workflow database. For audio that's unacceptable. Disabling it:&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="nl"&gt;"settings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"saveDataSuccessExecution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;⚠ &lt;strong&gt;This is the trap with teeth.&lt;/strong&gt; If you edit a workflow via the n8n API and&lt;br&gt;
omit the &lt;code&gt;settings&lt;/code&gt; object, the update &lt;strong&gt;silently resets it&lt;/strong&gt; and you go back to&lt;br&gt;
logging audio. Nothing warns you. Verify after every workflow change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -H &lt;span class="s2"&gt;"X-N8N-API-KEY: $KEY"&lt;/span&gt; \
  http://localhost:5678/api/v1/workflows/&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;id&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; \
  &lt;span class="o"&gt;|&lt;/span&gt; jq &lt;span class="s1"&gt;'.settings'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trap 4: the stock nginx config hijacks port 80
&lt;/h2&gt;

&lt;p&gt;I run the gateway as an nginx container. It kept returning 404s for paths that&lt;br&gt;
were definitely configured — because the stock &lt;code&gt;default.conf&lt;/code&gt; also declares a&lt;br&gt;
server block on port 80, and it wins as the default server. My config was&lt;br&gt;
loaded and correct, and simply never saw the traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# remove the stock config before starting&lt;/span&gt;
command: &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  sh -c &lt;span class="s2"&gt;"rm -f /etc/nginx/conf.d/default.conf &amp;amp;&amp;amp; nginx -g 'daemon off;'"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two smaller notes from the same build, both worth knowing before you deploy to&lt;br&gt;
a NAS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You can't bind-mount a config file you haven't created yet&lt;/strong&gt; — the deploy
fails with &lt;em&gt;"Bind mount failed: ... does not exist."&lt;/em&gt; On DSM the obvious
workaround of writing the file from inside another container doesn't work
either, because containers see only their own mounts, never the real host
filesystem. I ended up base64-embedding the config in the compose &lt;code&gt;command&lt;/code&gt;
and writing it at startup — no host filesystem access needed at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;/volume1&lt;/code&gt; mounts available to containers are read-only&lt;/strong&gt;, so the
obvious write paths are closed.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The authorisation model, honestly
&lt;/h2&gt;

&lt;p&gt;Two layers, and it's worth being precise about what each one buys you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-device API keys.&lt;/strong&gt; Each device gets its own key, so revoking a lost phone&lt;br&gt;
doesn't affect anyone else:&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="nl"&gt;"iphone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="s2"&gt;"stt_&amp;lt;random&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"ipad"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="s2"&gt;"stt_&amp;lt;random&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"laptop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="s2"&gt;"stt_&amp;lt;random&amp;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 n8n workflow checks the &lt;code&gt;Authorization&lt;/code&gt; header against this list and&lt;br&gt;
returns a real 403 when it doesn't match. One subtlety: an API key in a URL&lt;br&gt;
query string lands in proxy logs and browser history — keep it in the header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this protects.&lt;/strong&gt; It stops unauthorised use of your GPU and keeps the&lt;br&gt;
endpoint from being an open transcription service on the internet. What it does&lt;br&gt;
not do is encrypt the audio — that's TLS at the proxy layer, which is why the&lt;br&gt;
public entry point is HTTPS-only.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;access control for a private service&lt;/strong&gt;, not a hardened public API.&lt;br&gt;
There's no rate limiting and no per-key quota yet. For an internal office&lt;br&gt;
deployment across a handful of known devices that's proportionate; if you were&lt;br&gt;
opening it to third parties you'd want limits, logging and rotation on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify the bind address first, not the firewall.&lt;/strong&gt; Two hours went into
testing the wrong layer for a problem that &lt;code&gt;netstat&lt;/code&gt; answers in one line.
Both failures are "connection timed out", and I should have known that
loopback-only refuses everything regardless of firewall state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check what the toolkit installer actually installed.&lt;/strong&gt; I trusted
&lt;code&gt;nvcc --version&lt;/code&gt; as proof of a working toolchain. A compiler that runs but
can't find its own headers is not installed, and the error message will not
tell you that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set logging policy before wiring the pipeline, not after.&lt;/strong&gt; I configured
&lt;code&gt;saveDataSuccessExecution: none&lt;/code&gt; during the build, but the same edit through
the API would have silently re-enabled audio retention with no warning.
Privacy settings that can be reset by an unrelated update need a verification
step.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Four devices — a Windows development PC, an iPhone, an iPad and Android — all&lt;br&gt;
dictating through one GPU on my own network. Typical transcription of a short&lt;br&gt;
sentence is well under a second, and the end-to-end request through all four&lt;br&gt;
layers returns a real transcript in about 2 seconds.&lt;/p&gt;

&lt;p&gt;The measured win that matters most is memory. Because the model unloads when&lt;br&gt;
idle, a toggle drops GPU usage from &lt;strong&gt;4,906 MiB to 1,321 MiB&lt;/strong&gt; — about 3.6 GB&lt;br&gt;
returned — so the machine can go back to development work, local LLM inference&lt;br&gt;
or image generation, then load the model again on the next dictation.&lt;/p&gt;

&lt;p&gt;And the honest framing on speed: &lt;strong&gt;over 3x the throughput of typing (65%+ time cut)&lt;/strong&gt; is the&lt;br&gt;
correct figure for prose (130–150 wpm spoken vs ~40 wpm typed). It's less dramatic for&lt;br&gt;
code, where you're thinking more than typing. The gain is largest for exactly&lt;br&gt;
the work offices drown in — email, documentation, notes, contracts, support&lt;br&gt;
replies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Could this work for your office?
&lt;/h2&gt;

&lt;p&gt;The pieces are unglamorous and the payoff is immediate: one machine with a GPU,&lt;br&gt;
open-source software, and a delivery layer for authentication. No per-seat&lt;br&gt;
subscription, no minute quotas, no audio leaving your premises — and the whole&lt;br&gt;
office dictating instead of typing.&lt;/p&gt;

&lt;p&gt;I build exactly this kind of internal infrastructure: self-hosted services with&lt;br&gt;
proper auth, GPU workloads that share hardware sensibly, and the integration&lt;br&gt;
layer that makes them usable from the devices your staff already carry. If you&lt;br&gt;
want speech-to-text (or another internal API) running on your own hardware —&lt;br&gt;
or on a workstation you already own — that's the work I do.&lt;/p&gt;

&lt;p&gt;Reach me at &lt;a href="mailto:me@hoelee.com?subject=Self-hosted%20speech-to-text"&gt;me@hoelee.com&lt;/a&gt;&lt;br&gt;
or WhatsApp &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;, or see what I do at&lt;br&gt;
&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>selfhosted</category>
      <category>automation</category>
    </item>
    <item>
      <title>Why Telegram Bot Notifications Die: IPv6, DNS, and a 400 That Took a Day</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Wed, 11 Nov 2026 04:25:48 +0000</pubDate>
      <link>https://dev.to/hoelee/why-telegram-bot-notifications-die-ipv6-dns-and-a-400-that-took-a-day-3jn3</link>
      <guid>https://dev.to/hoelee/why-telegram-bot-notifications-die-ipv6-dns-and-a-400-that-took-a-day-3jn3</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/why-telegram-bot-notifications-die/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run an automated monitoring pipeline that watches Carousell for newly-listed&lt;br&gt;
items and pings me on Telegram when something fresh appears. It is one container&lt;br&gt;
on my Synology NAS, driven through Portainer, with a single Python process as&lt;br&gt;
&lt;code&gt;PID 1&lt;/code&gt; on a Docker bridge network. One day it stopped pinging. The container was healthy, the scraper was still archiving&lt;br&gt;
listings into NocoDB — but every notification died with the same three&lt;br&gt;
characters in the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;telegram sendPhoto failed: 400 ... nginx/1.30.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That string, &lt;code&gt;nginx/1.30.1&lt;/code&gt;, became the villain of a day-long debugging&lt;br&gt;
session. This is the story of how a deceptively small bug turned out to be&lt;br&gt;
three separate problems stacked on top of each other — and how I'd find each&lt;br&gt;
one faster next time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The bot had two jobs: &lt;strong&gt;collect&lt;/strong&gt; listings, and &lt;strong&gt;notify&lt;/strong&gt; me. Collection&lt;br&gt;
worked. Notification didn't. The log said &lt;code&gt;400 Bad Request&lt;/code&gt; and pointed at a&lt;br&gt;
server identifying itself as &lt;code&gt;nginx/1.30.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My first assumption was textbook: &lt;strong&gt;something in the network is intercepting&lt;br&gt;
the request.&lt;/strong&gt; &lt;code&gt;nginx/1.30.1&lt;/code&gt; is not a header I associated with Telegram's&lt;br&gt;
API. A 400 with an HTML body (Telegram returns JSON even on errors) smelled&lt;br&gt;
like a local reverse proxy or a VPN gateway rejecting traffic before it ever&lt;br&gt;
left the machine.&lt;/p&gt;

&lt;p&gt;Assumptions like that are cheap. They're also wrong a lot. Let me walk&lt;br&gt;
through what I actually found.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I tried, and why each thing failed
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Round 1: it must be DNS / IPv6
&lt;/h3&gt;

&lt;p&gt;I checked &lt;code&gt;getMe&lt;/code&gt; from inside the container. It worked. Then I checked&lt;br&gt;
&lt;code&gt;sendMessage&lt;/code&gt;. It failed. Same token, same container, one method up, one down.&lt;/p&gt;

&lt;p&gt;The giveaway was in how the two endpoints resolved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# inside the container&lt;/span&gt;
$ getent hosts api.telegram.org
2001:67c:4e8:f004::9   api.telegram.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container resolved &lt;code&gt;api.telegram.org&lt;/code&gt; to &lt;strong&gt;IPv6 only&lt;/strong&gt; — a single &lt;code&gt;AAAA&lt;/code&gt;&lt;br&gt;
record and no &lt;code&gt;A&lt;/code&gt; record. The container ran on a Docker bridge network with&lt;br&gt;
&lt;strong&gt;no IPv6 connectivity&lt;/strong&gt;. So every request that needed &lt;code&gt;api.telegram.org&lt;/code&gt; was&lt;br&gt;
trying to reach an IPv6 address it had no route to.&lt;/p&gt;

&lt;p&gt;The fix looked obvious: pin the IPv4 address with &lt;code&gt;extra_hosts&lt;/code&gt;. I did, and&lt;br&gt;
the DNS answer became exactly what I expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;149.154.167.220   api.telegram.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the notifications &lt;strong&gt;still failed.&lt;/strong&gt; Same 400. My proud fix did nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Round 2: I picked the wrong IP
&lt;/h3&gt;

&lt;p&gt;Here's a mistake worth remembering: I hard-coded &lt;code&gt;149.154.167.220&lt;/code&gt; from&lt;br&gt;
memory. When I finally queried a public resolver, the real &lt;code&gt;A&lt;/code&gt; record for&lt;br&gt;
&lt;code&gt;api.telegram.org&lt;/code&gt; was different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;149.154.166.110   api.telegram.org   # what DNS actually says
149.154.167.220   api.telegram.org   # what I hard-coded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;167.220&lt;/code&gt; is &lt;em&gt;inside&lt;/em&gt; Telegram's IP range (&lt;code&gt;149.154.160.0/20&lt;/code&gt;), so it's not&lt;br&gt;
"wrong" in the sense of being somebody else's server — but it's not the&lt;br&gt;
active endpoint, and bot-API traffic to it behaved unpredictably. I switched&lt;br&gt;
to &lt;code&gt;166.110&lt;/code&gt;. It still failed. So the IP wasn't the whole story either.&lt;/p&gt;
&lt;h3&gt;
  
  
  Round 3: the same command, two different outcomes
&lt;/h3&gt;

&lt;p&gt;This was the moment the whole thing got weird. Inside one container, at&lt;br&gt;
almost the same second:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;strong&gt;long-running monitor process&lt;/strong&gt; (&lt;code&gt;PID 1&lt;/code&gt;) → &lt;code&gt;sendPhoto&lt;/code&gt; failed, 400&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;fresh &lt;code&gt;docker exec&lt;/code&gt; process&lt;/strong&gt; running identical code → &lt;code&gt;sendPhoto&lt;/code&gt;
succeeded, 200&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same container. Same code. Same payload. Same second.&lt;/p&gt;

&lt;p&gt;I ruled out token, chat_id, emoji in the caption, Unicode quotes, image&lt;br&gt;
format, environment variables, and the image URL itself — I download-tested&lt;br&gt;
the photo and it was a perfectly valid JPEG. Nothing in the code differed&lt;br&gt;
between the two paths.&lt;/p&gt;

&lt;p&gt;When you hit a contradiction like that — "identical inputs, opposite&lt;br&gt;
outputs" — the answer is usually that the inputs &lt;em&gt;aren't&lt;/em&gt; identical. In my &lt;code&gt;docker exec&lt;/code&gt; test, I had tested against a lightweight sample image; but the PID 1 loop was processing real scraped listing photos where binary byte sequences happened to break multipart boundary parsing. The binary payloads were not the same. But&lt;br&gt;
finding the difference took one more frame change.&lt;/p&gt;
&lt;h3&gt;
  
  
  Round 4: &lt;code&gt;nginx/1.30.1&lt;/code&gt; is Telegram
&lt;/h3&gt;

&lt;p&gt;I re-read the raw 400 response instead of assuming. The response was an HTML&lt;br&gt;
"400 Bad Request" page, and the &lt;code&gt;Server&lt;/code&gt; header was &lt;code&gt;nginx/1.30.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That nginx is Telegram's own edge.&lt;/strong&gt; Telegram's API sits behind nginx&lt;br&gt;
(version 1.30.1), and when a request is malformed &lt;em&gt;before&lt;/em&gt; it reaches their&lt;br&gt;
application layer, nginx itself answers with a plain 400 page — no JSON, no&lt;br&gt;
friendly error code.&lt;/p&gt;

&lt;p&gt;That reframed everything. The request wasn't being intercepted. It was&lt;br&gt;
&lt;strong&gt;reaching Telegram, and Telegram's nginx was rejecting it as malformed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And the malformed thing was the one part I hadn't scrutinized: the&lt;br&gt;
&lt;code&gt;sendPhoto&lt;/code&gt; multipart body.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;My original helper assembled the multipart body by treating headers and binary payloads as a list of "lines" and joining them with &lt;code&gt;b"\r\n".join(body_lines)&lt;/code&gt; — the kind of "obviously fine" shortcut that ships a framing bug:&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;# BROKEN: pre-terminated chunks or line-split binary data re-joined with b"\r\n"&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;"\r\n"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body_lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It fails in two ways, and neither is &lt;code&gt;bytes.join()&lt;/code&gt; rewriting the JPEG — &lt;code&gt;b"\r\n".join()&lt;/code&gt; only inserts the delimiter &lt;em&gt;between&lt;/em&gt; list elements and never touches bytes inside an element. The damage happens upstream. First, if the payload ever passes through line-oriented handling such as &lt;code&gt;.splitlines()&lt;/code&gt;, every lone &lt;code&gt;0x0D&lt;/code&gt; or &lt;code&gt;0x0A&lt;/code&gt; byte inside the compressed image stream is treated as a line break and rewritten to &lt;code&gt;\r\n&lt;/code&gt;, which mutates the JPEG &lt;em&gt;and&lt;/em&gt; desynchronises the &lt;code&gt;Content-Length&lt;/code&gt; that was computed from the original file. Second, if any chunk in &lt;code&gt;body_lines&lt;/code&gt; already carries its own trailing &lt;code&gt;\r\n&lt;/code&gt;, the join adds a second one, so the body reads &lt;code&gt;--boundary\r\n\r\nContent-Disposition: ...&lt;/code&gt; — and in MIME framing a blank line terminates the part headers, so that part becomes an anonymous body whose first line is the &lt;code&gt;Content-Disposition&lt;/code&gt; header. Telegram's edge &lt;code&gt;nginx/1.30.1&lt;/code&gt; rejects that as malformed with an HTML 400 long before the Bot API parses anything.&lt;/p&gt;

&lt;p&gt;That also closes the Round 3 contradiction. The &lt;code&gt;docker exec&lt;/code&gt; one-liner I typed by hand sent a minimal two-field payload from the freshly edited script, while the long-running &lt;code&gt;PID 1&lt;/code&gt; process was still executing the old in-memory framing path against a real marketplace JPEG whose bytes were long enough to hit the doubled-&lt;code&gt;\r\n&lt;/code&gt; case — same function, different input, one 200 and one 400.&lt;/p&gt;

&lt;p&gt;The correct approach is to frame every part explicitly and concatenate the byte chunks verbatim with &lt;code&gt;b"".join(parts)&lt;/code&gt;:&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;boundary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"----tg"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;token_hex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"--{boundary}\r\n"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="n"&gt;b&lt;/span&gt;&lt;span class="s1"&gt;'Content-Disposition: form-data; name="chat_id"\r\n\r\n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{chat_id}\r\n"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"--{boundary}\r\n"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="n"&gt;b&lt;/span&gt;&lt;span class="s1"&gt;'Content-Disposition: form-data; name="caption"\r\n\r\n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{caption}\r\n"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"--{boundary}\r\n"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="n"&gt;b&lt;/span&gt;&lt;span class="s1"&gt;'Content-Disposition: form-data; name="photo"; '&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="s1"&gt;'filename="image.jpg"\r\nContent-Type: image/jpeg\r\n\r\n'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;image_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;# ← binary stays untouched&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;"\r\n"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"--{boundary}--\r\n"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&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="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# concatenate raw byte chunks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is &lt;code&gt;b"".join(parts)&lt;/code&gt; instead of &lt;code&gt;b"\r\n".join(...)&lt;/code&gt;: each part&lt;br&gt;
is already a complete chunk with its own framing, and we concatenate them&lt;br&gt;
verbatim rather than inserting a delimiter &lt;em&gt;between every element&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;There's a second, higher-level fix worth naming: &lt;strong&gt;stop asking Telegram to&lt;br&gt;
download the image for you.&lt;/strong&gt; My earlier code passed the image as a &lt;em&gt;URL&lt;/em&gt; and&lt;br&gt;
let Telegram fetch it:&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="nf"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sendPhoto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"photo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"caption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;caption&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;That means Telegram's servers have to reach out to the source CDN — and if&lt;br&gt;
that CDN is flaky or geo-blocks Telegram's crawlers, you get intermittent&lt;br&gt;
failures that are impossible to reproduce locally. Downloading the image&lt;br&gt;
yourself, then uploading the bytes, removes an entire class of flakiness and&lt;br&gt;
gives you a chance to validate the bytes before you send them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the raw response, not the assumption.&lt;/strong&gt; &lt;code&gt;nginx/1.30.1&lt;/code&gt; sat in logs
for hours while I chased phantom proxies. Look up who owns the header
before inventing a man-in-the-middle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify a hard-coded IP against a live resolver.&lt;/strong&gt; Memory is not DNS.
&lt;code&gt;dig +short api.telegram.org&lt;/code&gt; takes three seconds and would have saved me
a whole round.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "identical inputs, opposite outputs" as a lie.&lt;/strong&gt; The inputs were
never identical — the two processes differed somewhere I wasn't looking.
The honest move is to diff the bytes, not the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never hand-join binary data with a text delimiter.&lt;/strong&gt; &lt;code&gt;b"\r\n".join()&lt;/code&gt;
only inserts a delimiter between list elements — so any element that already
ends in &lt;code&gt;\r\n&lt;/code&gt; gains a second one, and any binary stream that went through
&lt;code&gt;.splitlines()&lt;/code&gt; is already rewritten. Frame each part yourself, then use
&lt;code&gt;b"".join()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;After the multipart fix, the monitor pushed a real notification through on&lt;br&gt;
the next tick — image and all. I also folded in a retry queue so a failed&lt;br&gt;
notification stays marked un-sent and is retried 30 seconds later instead of&lt;br&gt;
silently vanishing. The whole episode cost the better part of a day, but it&lt;br&gt;
turned into exactly the kind of bug I'll never trip over twice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This monitoring pipeline was originally built for high-frequency internal inspection, but the troubleshooting lessons apply to any production service that relies on outbound notifications. If you've got a monitor, a scraper, or&lt;br&gt;
an alert pipeline that needs to reach people reliably, let's talk.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I build reliable notification and monitoring pipelines — Telegram/WhatsApp&lt;br&gt;
alerts, scrapers, and the infrastructure around them.&lt;/strong&gt; Tell me what you need&lt;br&gt;
to watch and where you need it delivered:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://wa.me/60127972969?text=Hi%20Hoelee%2C%20I%20need%20a%20monitoring%2Fnotification%20pipeline" rel="noopener noreferrer"&gt;WhatsApp me →&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="mailto:me@hoelee.com?subject=Monitoring%20%2F%20notification%20pipeline"&gt;Email me →&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Migrating a CodeIgniter 4 App From IIS to OpenLiteSpeed</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Tue, 10 Nov 2026 04:25:46 +0000</pubDate>
      <link>https://dev.to/hoelee/migrating-a-codeigniter-4-app-from-iis-to-openlitespeed-k3i</link>
      <guid>https://dev.to/hoelee/migrating-a-codeigniter-4-app-from-iis-to-openlitespeed-k3i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/migrating-codeigniter-iis-to-openlitespeed/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I maintain a numerology report generator: a CodeIgniter 4 application that takes a birth date and a name, runs them through a numerology engine, and renders a 19-page A4 report. It had run on Windows Server with IIS for years. In September 2026 I moved it to OpenLiteSpeed on CyberPanel, on Linux, on a different domain.&lt;/p&gt;

&lt;p&gt;The migration itself was unremarkable — copy the files, point the docroot at &lt;code&gt;public/&lt;/code&gt;, install dependencies. What made it worth writing about is that &lt;strong&gt;the first unauthenticated request to the new host fatally crashed on two routes that had been working fine on IIS for years.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Both bugs were real. Both were present on IIS the whole time. Neither was visible, because IIS had an authentik SSO gate in front of the exact routes that were broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;If you run a self-hosted app behind an authentication gateway, your auth layer is doing more than protecting data — it is also &lt;strong&gt;hiding your bugs&lt;/strong&gt;. Anything that only breaks for unauthenticated users never runs unauthenticated, so it never fails. The failure surfaces at the worst possible moment: when you migrate, remove the gate, or expose the route to the public internet.&lt;/p&gt;

&lt;p&gt;In my case the app had been reporting "healthy" for as long as I'd owned it. The moment I pointed a new domain at it, two routes returned HTTP 500. Nothing had changed in the code. The only thing that changed was that the gate was gone.&lt;/p&gt;

&lt;p&gt;The fix for both took about twenty minutes. Finding them took two hours, because the actual error messages were hidden behind CodeIgniter's CLI error renderer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The app is a fairly ordinary CI4 project with an unusual architecture detail: the report engine is not a library, it is a &lt;strong&gt;set of HTTP endpoints on the same host&lt;/strong&gt;. The controllers generate a request, &lt;code&gt;curl&lt;/code&gt; it back to &lt;code&gt;/api/single&lt;/code&gt; on their own domain, and the engine returns JSON with the computed numbers. The controller then renders that into the printable report view.&lt;/p&gt;

&lt;p&gt;That self-call design is what made &lt;code&gt;CONST_IIS_INTERNAL_BASE&lt;/code&gt; necessary on IIS. Let me come back to it — it turns out to be the interesting part.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;Windows Server&lt;/td&gt;
&lt;td&gt;Ubuntu (CyberPanel)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web server&lt;/td&gt;
&lt;td&gt;IIS&lt;/td&gt;
&lt;td&gt;OpenLiteSpeed 1.9.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;8.4 (Windows build)&lt;/td&gt;
&lt;td&gt;8.4.25 (lsphp84)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docroot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;C:\inetpub\calc.hoelee.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home/&amp;lt;domain&amp;gt;/public_html/public&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth gate&lt;/td&gt;
&lt;td&gt;authentik SSO on &lt;code&gt;/lifecode&lt;/code&gt;, &lt;code&gt;/api/*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 1: Point the docroot at &lt;code&gt;public/&lt;/code&gt;, not the project root
&lt;/h2&gt;

&lt;p&gt;CI4 ships with a two-folder layout. &lt;code&gt;app/&lt;/code&gt;, &lt;code&gt;vendor/&lt;/code&gt;, &lt;code&gt;writable/&lt;/code&gt; and &lt;code&gt;.env&lt;/code&gt; live in the project root. Only &lt;code&gt;public/&lt;/code&gt; is meant to be web-accessible.&lt;/p&gt;

&lt;p&gt;CyberPanel creates the document root as &lt;code&gt;public_html&lt;/code&gt;. My first instinct was to extract the whole project into &lt;code&gt;public_html&lt;/code&gt; and leave the docroot alone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That would have exposed &lt;code&gt;app/&lt;/code&gt;, &lt;code&gt;vendor/&lt;/code&gt;, the &lt;code&gt;.env&lt;/code&gt; file, and &lt;code&gt;writable/&lt;/code&gt; session data over HTTP.&lt;/strong&gt; The &lt;code&gt;.env&lt;/code&gt; in this project contains a webhook credential. Anyone who guessed &lt;code&gt;/../.env&lt;/code&gt; — or just fetched &lt;code&gt;/.env&lt;/code&gt;, since the file sits directly under a served directory — gets it.&lt;/p&gt;

&lt;p&gt;The correct layout is to extract into &lt;code&gt;public_html&lt;/code&gt; and then repoint the vhost docroot one level deeper, at &lt;code&gt;public_html/public&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# extract the project so that app/, vendor/ and .env sit UNDER public_html&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /home/&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;domain&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/public_html
tar -xzf /tmp/deploy.tar.gz

&lt;span class="c"&gt;# the docroot must be public_html/public, NOT public_html&lt;/span&gt;
sudo sed -i &lt;span class="s1"&gt;'s#/home/&amp;lt;domain&amp;gt;/public_html$#/home/&amp;lt;domain&amp;gt;/public_html/public#'&lt;/span&gt; \
  /usr/local/lsws/conf/vhosts/&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;domain&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/vhost.conf

sudo /usr/local/lsws/bin/lshttpd -t
sudo systemctl restart lsws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most important step in the migration, and it is the one most guides skip. If you do nothing else, do this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: The two fatals IIS was hiding
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fatal 1 — &lt;code&gt;env()&lt;/code&gt; called too early
&lt;/h3&gt;

&lt;p&gt;The first 500 had no body at all. That is unusual — CI4 normally renders something. An empty 500 usually means PHP died before the framework's error handler was installed.&lt;/p&gt;

&lt;p&gt;CI4's bootstrap loads &lt;code&gt;app/Config/Constants.php&lt;/code&gt; very early in &lt;code&gt;Boot::bootWeb()&lt;/code&gt;, via &lt;code&gt;Boot::loadConstants()&lt;/code&gt;. That happens &lt;strong&gt;before&lt;/strong&gt; the &lt;code&gt;Common.php&lt;/code&gt; helper file is loaded, which is where &lt;code&gt;env()&lt;/code&gt; is defined.&lt;/p&gt;

&lt;p&gt;So this line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Config/Constants.php  — BROKEN&lt;/span&gt;
&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CONST_fullBase'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'hoelee.fullBase'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fatal error: Uncaught Error: Call to undefined function env()
in app/Config/Constants.php:96
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is absolute: &lt;strong&gt;&lt;code&gt;Constants.php&lt;/code&gt; may only contain plain constants.&lt;/strong&gt; No &lt;code&gt;env()&lt;/code&gt;, no &lt;code&gt;getenv()&lt;/code&gt;, no config helper. If you need environment-dependent values there, define them further along the bootstrap — or make the constant a fallback and read the real value later.&lt;/p&gt;

&lt;p&gt;That is exactly what I did. &lt;code&gt;CONST_fullBase&lt;/code&gt; became a plain string, and the helper that consumes it checks the framework's own &lt;code&gt;app.baseURL&lt;/code&gt; (which &lt;em&gt;is&lt;/em&gt; &lt;code&gt;.env&lt;/code&gt;-driven) first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Helpers/hoelee_helper.php&lt;/span&gt;
&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getFullBase&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nv"&gt;$selfCall&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// the .env-driven baseURL wins; CONST_fullBase is only a fallback&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$selfCall&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CONST_IIS_INTERNAL_BASE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;CONST_IIS_INTERNAL_BASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CONST_IIS_INTERNAL_BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&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="nv"&gt;$appBase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'App'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;baseURL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$appBase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$appBase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'CONST_fullBase'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;CONST_fullBase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;rtrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CONST_fullBase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This one was my own doing.&lt;/strong&gt; I had introduced it during a secrets-cleanup refactor in the same week — I moved a hardcoded URL into &lt;code&gt;.env&lt;/code&gt; and called &lt;code&gt;env()&lt;/code&gt; in &lt;code&gt;Constants.php&lt;/code&gt; to read it. It worked on my machine because the local &lt;code&gt;.env&lt;/code&gt; was being read through a different path. It never worked on a clean boot. I caught it within an hour &lt;em&gt;only because&lt;/em&gt; I deployed and tested; a code review would plausibly have waved it through.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fatal 2 — &lt;code&gt;parent::__construct()&lt;/code&gt; in a CI4 controller
&lt;/h3&gt;

&lt;p&gt;The second failure was on &lt;code&gt;/lifecode&lt;/code&gt;, and this one is a genuine pre-existing bug in the application — not something I introduced.&lt;/p&gt;

&lt;p&gt;The error, once I got past the CLI renderer, was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Cannot call constructor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CI4's base &lt;code&gt;CodeIgniter\Controller&lt;/code&gt; class &lt;strong&gt;has no constructor&lt;/strong&gt;. It implements &lt;code&gt;initController()&lt;/code&gt;, which the framework calls with the request, response, and logger objects. The standard pattern is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// correct CI4 pattern&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;initController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;RequestInterface&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;ResponseInterface&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nc"&gt;LoggerInterface&lt;/span&gt; &lt;span class="nv"&gt;$logger&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kc"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;initController&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$logger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// your setup here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But &lt;code&gt;Lifecode.php&lt;/code&gt; and &lt;code&gt;ApiEn.php&lt;/code&gt; declared a classic constructor and called &lt;code&gt;parent::__construct()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN — CodeIgniter\Controller has no __construct()&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;__construct&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="kc"&gt;parent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;__construct&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="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;parent::__construct()&lt;/code&gt; on a parent class that does not define &lt;code&gt;__construct()&lt;/code&gt; is a fatal in PHP 8. Converting both controllers to &lt;code&gt;initController()&lt;/code&gt; fixed it — and required adding the three &lt;code&gt;use&lt;/code&gt; statements for the interface types in the new signature.&lt;/p&gt;

&lt;p&gt;I checked whether the base class &lt;em&gt;really&lt;/em&gt; had no constructor rather than trusting the error message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;grep -n &lt;span class="s1"&gt;'function __construct\|function initController'&lt;/span&gt; \
  vendor/codeigniter4/framework/system/Controller.php
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;initController()&lt;/code&gt; came back. Worth doing — "Cannot call constructor" also fires when a parent &lt;em&gt;has&lt;/em&gt; a constructor that errors internally, and you want to know which case you are in before you rewrite the signature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why neither bug showed up on IIS
&lt;/h2&gt;

&lt;p&gt;This is the part that changed how I think about the deployment.&lt;/p&gt;

&lt;p&gt;On the IIS host, &lt;code&gt;/lifecode&lt;/code&gt; and &lt;code&gt;/api/*&lt;/code&gt; sat behind authentik SSO. An unauthenticated request to either returned &lt;strong&gt;HTTP 302 to &lt;code&gt;auth.hoelee.com&lt;/code&gt;&lt;/strong&gt; — it never reached the controller at all. The broken constructor was never executed. The route had presumably been broken since it was written, and it had never once been asked to serve a request.&lt;/p&gt;

&lt;p&gt;I confirmed this by comparing the two hosts directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -sI https://calc.hoelee.com/lifecode &lt;span class="o"&gt;|&lt;/span&gt; head -1
&lt;span class="c"&gt;# HTTP/2 302   &amp;lt;- authentik redirect; controller never runs&lt;/span&gt;

curl -sI http://&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;new-host&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/lifecode &lt;span class="o"&gt;|&lt;/span&gt; head -1
&lt;span class="c"&gt;# HTTP/1.1 500  &amp;lt;- no gate; controller runs and fatals&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 302 is why the bug survived. The app looked healthy because the unhealthy parts were unreachable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson, stated plainly: an auth gate in front of a route means that route has no working test coverage for its own code.&lt;/strong&gt; If you migrate or remove the gate, budget time to hit every previously-gated route unauthenticated before you call the migration done. On a small app that is a ten-line &lt;code&gt;curl&lt;/code&gt; loop. Here it would have found both bugs in seconds instead of two hours:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; p &lt;span class="k"&gt;in&lt;/span&gt; / /read/single /read/partner /lifecode /api/date /api/single&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-16s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"$p"&lt;/span&gt; &lt;span class="s2"&gt;"$(curl -s -o /dev/null -w '%{http_code}' https://&amp;lt;host&amp;gt;$p)"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: The self-call that stops being a loopback
&lt;/h2&gt;

&lt;p&gt;Now the interesting architectural consequence.&lt;/p&gt;

&lt;p&gt;The report engine is reached by the controller &lt;code&gt;curl&lt;/code&gt;-ing its own host. On IIS, &lt;code&gt;CONST_IIS_INTERNAL_BASE&lt;/code&gt; pointed that call at &lt;code&gt;http://localhost:7296&lt;/code&gt; — the loopback interface — so the request never left the machine and never touched TLS or DNS.&lt;/p&gt;

&lt;p&gt;On LiteSpeed that constant is deliberately left &lt;strong&gt;undefined&lt;/strong&gt;, so &lt;code&gt;getFullBase()&lt;/code&gt; falls through to &lt;code&gt;app.baseURL&lt;/code&gt;. Which means every report generation now makes a &lt;strong&gt;real outbound HTTPS request to the app's own public URL&lt;/strong&gt; and comes back in through Cloudflare.&lt;/p&gt;

&lt;p&gt;Which raises the obvious question: does that work at all?&lt;/p&gt;

&lt;p&gt;The answer is yes, but it is worth testing explicitly, because the failure mode is confusing. Here is the exact test — run it &lt;strong&gt;on the server&lt;/strong&gt;, not from your laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s1"&gt;'HTTPS self-call: %{http_code}\n'&lt;/span&gt; \
  -X POST &lt;span class="s1"&gt;'https://&amp;lt;domain&amp;gt;/api/single'&lt;/span&gt; -d &lt;span class="s1"&gt;'nameCn=test&amp;amp;dob=1990-01-01'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run it from the server.&lt;/strong&gt; From my Windows machine the same URL returned 404 and 500 — an artefact of DNS resolution and Cloudflare bot protection on my caller, not a server problem. Testing from the wrong host produces confident, wrong conclusions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the JSON, not just the status code.&lt;/strong&gt; A 200 with a truncated body is worse than a clean failure. I checked the response was parseable before trusting it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a real trade-off hiding here, and I have not fully resolved it: a public-URL self-call means report generation depends on Cloudflare being up, costs a TLS handshake per report, and occupies two PHP workers for the duration. On LiteSpeed with a small worker pool, a burst of concurrent reports could deadlock — each request waiting on another request that has no free worker. For the current traffic level it is fine. Before this scales, the self-call should move to an internal path that bypasses the CDN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Verify the actual output, not the status code
&lt;/h2&gt;

&lt;p&gt;A 200 on &lt;code&gt;/&lt;/code&gt; proves the landing page renders. It does not prove the report engine works, and the engine is the entire product. So the last step was generating a real report end to end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -X POST &lt;span class="s1"&gt;'https://&amp;lt;domain&amp;gt;/read/single'&lt;/span&gt; \
  -d &lt;span class="s1"&gt;'nameCn=test&amp;amp;dob=1990-01-01&amp;amp;gender=m'&lt;/span&gt; \
  -o report.html -w &lt;span class="s1"&gt;'HTTP:%{http_code} bytes:%{size_download}\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result: &lt;strong&gt;HTTP 200, 99,189 bytes, 19 A4 pages.&lt;/strong&gt; I ran it again with Chinese-character input to exercise the UTF-8 path through the engine — HTTP 200, 99,205 bytes, 19 pages, name rendering correctly, zero PHP warnings in the output.&lt;/p&gt;

&lt;p&gt;Byte-comparable page counts between the old and new host is the check that matters. It proves the fonts, the DPI, and the pagination logic all survived the move.&lt;/p&gt;

&lt;p&gt;A side note on the UTF-8 test: my first attempt at it returned a 500 and I nearly went bug-hunting. The cause was my own shell — the Chinese characters were being mangled by the terminal encoding on the way into &lt;code&gt;curl&lt;/code&gt;, so the app received invalid bytes. Running the same request from a script on the server, where the encoding is under my control, returned 200. &lt;strong&gt;Before you debug an encoding failure, confirm the bytes actually arriving at the server are the bytes you meant to send.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test unauthenticated routes before migrating, not after.&lt;/strong&gt; The whole two-hour debugging session was avoidable with a &lt;code&gt;curl&lt;/code&gt; loop over the route list. I now treat "list every route, hit it unauthenticated, record the status" as step zero of any migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not call &lt;code&gt;env()&lt;/code&gt; in &lt;code&gt;Constants.php&lt;/code&gt;.&lt;/strong&gt; I have left a comment in the file itself saying so, because the next person — probably me in six months — will be tempted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the framework version's project-space config before the move.&lt;/strong&gt; The same week, I upgraded CI4 from 4.6.3 to 4.7.4, and it fataled twice on properties the upgrade guide did not mention: &lt;code&gt;Config\App::$permittedURIChars&lt;/code&gt;, required by the 4.7 Router, and &lt;code&gt;Config\Format::$jsonEncodeDepth&lt;/code&gt;, required by the JSONFormatter — the second of which broke the engine's JSON self-call specifically. Composer updates &lt;code&gt;vendor/&lt;/code&gt; but never merges &lt;code&gt;app/Config/*.php&lt;/code&gt;, because those are project-space files. I ended up merging new properties into 14 config files. That is a separate post, but the migration lesson is the same shape: &lt;strong&gt;the framework tells you what changed in &lt;code&gt;vendor/&lt;/code&gt;; nothing tells you what changed in &lt;code&gt;app/&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm the docroot before anything else.&lt;/strong&gt; If I had extracted into &lt;code&gt;public_html&lt;/code&gt; and stopped, the app would have worked — and quietly served &lt;code&gt;.env&lt;/code&gt; over HTTP. A migration that works is not the same as a migration that is safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;One evening. Six routes verified 200, byte-comparable report output on both Chinese and ASCII input, zero PHP warnings, and two long-standing latent bugs removed from the codebase that IIS had been concealing behind an auth gate.&lt;/p&gt;

&lt;p&gt;The app is now running on OpenLiteSpeed with a properly separated docroot, on a host I can automate, with the credential in &lt;code&gt;.env&lt;/code&gt; rather than a constant.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Want this done for your own application?&lt;/strong&gt; I migrate PHP applications between IIS, Apache, nginx and LiteSpeed — including the awkward parts: self-calling architectures, SSO gates that hide bugs, and framework upgrades that touch project-space config.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp +60 12-797 2969&lt;/a&gt; · &lt;a href="mailto:me@hoelee.com?subject=CodeIgniter%20migration"&gt;me@hoelee.com&lt;/a&gt; · &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>webdev</category>
      <category>programming</category>
      <category>devops</category>
    </item>
    <item>
      <title>Authentik 2025.8 to 2026.8: The Breakable Parts Nobody Warns You About</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Mon, 09 Nov 2026 04:25:45 +0000</pubDate>
      <link>https://dev.to/hoelee/authentik-20258-to-20268-the-breakable-parts-nobody-warns-you-about-1o7e</link>
      <guid>https://dev.to/hoelee/authentik-20258-to-20268-the-breakable-parts-nobody-warns-you-about-1o7e</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/authentik-major-upgrade-gotchas/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run authentik as the single sign-on gate in front of my self-hosted stack —&lt;br&gt;
email, dashboard, Synology apps, a remote-access outpost. For a long while it&lt;br&gt;
sat on &lt;strong&gt;2025.8.3&lt;/strong&gt;, and a year of releases piled up. This is the story of&lt;br&gt;
bringing it all the way to &lt;strong&gt;2026.8.1&lt;/strong&gt; in one sitting, and everything that&lt;br&gt;
broke along the way — especially the one mistake that took Single Sign-On&lt;br&gt;
completely offline and made every internal app ask for a password again.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is authentik, and why run it?
&lt;/h2&gt;

&lt;p&gt;authentik is an open-source &lt;strong&gt;identity provider (IdP)&lt;/strong&gt; — the thing that answers&lt;br&gt;
one question over and over: &lt;em&gt;"who is this person, and are they allowed in?"&lt;/em&gt;&lt;br&gt;
Think of it as the front door to a whole building of apps. Instead of every&lt;br&gt;
app running its own username/password screen (and its own list of bugs, its own&lt;br&gt;
"forgot password" flow, its own 2FA), you make all of them ask authentik instead.&lt;/p&gt;

&lt;p&gt;It speaks the protocols that matter for real deployments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OIDC / OAuth2&lt;/strong&gt; — the modern single sign-on standard (what "Sign in with
Google" uses under the hood).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SAML&lt;/strong&gt; — the enterprise standard (for tools like Grafana, Jira, or any
legacy line-of-business app).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LDAP&lt;/strong&gt; — so older clients and NAS boxes that only know LDAP can join too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy / forward-auth&lt;/strong&gt; — it sits in front of an app via a reverse proxy and
gatekeeps access before a request ever reaches the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The practical benefits, in order of how much they actually matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One login for everything.&lt;/strong&gt; Log in once, move freely between every app.
Users stop juggling a dozen passwords (and the support tickets that go with
them).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One place to lock things down.&lt;/strong&gt; MFA, password policy, session limits, and
account recovery live in authentik once — not re-implemented per app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One place to audit.&lt;/strong&gt; Every login, on every app, for every user, in one
log. That's the difference between "we think nothing happened" and "we can
prove it" when a question comes up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-contained and self-hosted.&lt;/strong&gt; You own the data. No per-user SaaS fees
that scale with your headcount, no lock-in — it's AGPL, runs in Docker.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a small business or a solo operator running twenty-something services&lt;br&gt;
(email, dashboards, NAS apps, internal tools), authentik is the difference&lt;br&gt;
between "every app has its own flimsy password" and "one hardened front door."&lt;/p&gt;
&lt;h2&gt;
  
  
  Why upgrade at all
&lt;/h2&gt;

&lt;p&gt;Version 2025.8.3 wasn't broken. But it had fallen far enough behind that a&lt;br&gt;
stack of CVEs had landed in the releases after it, and I was getting ready to&lt;br&gt;
do per-application branding. authentik's own policy is that you can't jump&lt;br&gt;
major versions — it enforces a stepwise path. So the plan was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2025.8.3 → 2025.10 → 2025.12 → 2026.2 → 2026.4 → 2026.6 → 2026.8
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six hops, one at a time, with a migration and a health-check between each.&lt;br&gt;
Before touching anything, the one non-negotiable step: &lt;strong&gt;back up the database&lt;/strong&gt;.&lt;br&gt;
authentik doesn't support downgrades. If a migration half-runs, you're restoring&lt;br&gt;
from dump, not rolling back an image tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;authentik-postgres pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; authentik authentik &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; authentik-backup.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfall 1: Portainer is the source of truth, not the compose file
&lt;/h2&gt;

&lt;p&gt;My first instinct was to edit the &lt;code&gt;docker-compose.yml&lt;/code&gt; on disk and &lt;code&gt;up&lt;/code&gt; it.&lt;br&gt;
Wrong. The stack is managed by &lt;strong&gt;Portainer&lt;/strong&gt; (stack 143), which keeps the real&lt;br&gt;
compose and the real environment variables in its own store. The &lt;code&gt;.env&lt;/code&gt; on disk&lt;br&gt;
was stale — its &lt;code&gt;PG_PASS&lt;/code&gt; didn't match what Portainer actually ran.&lt;/p&gt;

&lt;p&gt;The correct update path is via the Portainer API, not the filesystem:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Update the image tags in the compose content.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker pull&lt;/code&gt; the new images &lt;em&gt;first&lt;/em&gt; (so the API call doesn't time out mid-pull).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker stop&lt;/code&gt; + &lt;code&gt;docker rm&lt;/code&gt; the running containers (fixed &lt;code&gt;container_name&lt;/code&gt;
will otherwise collide on redeploy).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUT /api/stacks/143?endpointId=2&lt;/code&gt; with the new &lt;code&gt;StackFileContent&lt;/code&gt; + &lt;code&gt;Env&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I hit the colliding-container error, the pull-timeout error, and a network-attach&lt;br&gt;
problem where the rebuilt &lt;code&gt;authentik-server&lt;/code&gt; only joined one of its two networks&lt;br&gt;
and couldn't resolve &lt;code&gt;postgres-server&lt;/code&gt;. Each one is a five-minute fix once you&lt;br&gt;
know what you're looking at, but together they ate the better part of the evening.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfall 2: the storage mount moved (2025.12)
&lt;/h2&gt;

&lt;p&gt;Up to 2025.12, brand assets lived under &lt;code&gt;/media/&lt;/code&gt; and were served at &lt;code&gt;/media/...&lt;/code&gt;.&lt;br&gt;
After 2025.12, the storage layout changed: files moved to a &lt;code&gt;/data/media&lt;/code&gt;&lt;br&gt;
structure served under a new &lt;code&gt;/files/media/public/&amp;lt;name&amp;gt;?token=...&lt;/code&gt; URL with a&lt;br&gt;
JWT signature. My containers were still mounting &lt;code&gt;./media:/media&lt;/code&gt;, so every&lt;br&gt;
logo, favicon, and background image 404'd the moment I crossed that version.&lt;/p&gt;

&lt;p&gt;The fix is the documented migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; data &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv &lt;/span&gt;media data/media
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and change the mounts to &lt;code&gt;./data:/data&lt;/code&gt;. The new file backend also refuses to&lt;br&gt;
work unless &lt;code&gt;/data&lt;/code&gt; is an actual mount point — the earlier version of my fix&lt;br&gt;
used a symlink, which the backend's &lt;code&gt;is_mount()&lt;/code&gt; check rejected with&lt;br&gt;
&lt;code&gt;No file management backend configured&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfall 3: RBAC migration leaves a poisoned session table
&lt;/h2&gt;

&lt;p&gt;2025.12 removed the old &lt;code&gt;authentik_core.User_groups&lt;/code&gt; model in favor of the RBAC&lt;br&gt;
rework. The migration ran clean, but &lt;strong&gt;old sessions&lt;/strong&gt; still held references to&lt;br&gt;
the deleted model. Result: the login page kept throwing&lt;br&gt;
&lt;code&gt;LookupError: App 'authentik_core' doesn't have a 'User_groups' model&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not &lt;code&gt;django_session&lt;/code&gt; — that was empty. The real culprit was authentik's own&lt;br&gt;
&lt;code&gt;authentik_core_session&lt;/code&gt; table. Clearing it (and the other session tables)&lt;br&gt;
forced everyone to log in again and cleared the error:&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;TRUNCATE&lt;/span&gt; &lt;span class="n"&gt;authentik_core_session&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;-- named authentik_core_authenticatedsession in some versions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One side effect worth knowing: this also invalidates every OIDC refresh token&lt;br&gt;
your apps were holding. They'll bounce the user to a fresh login once, then&lt;br&gt;
recover. It's a one-time annoyance, not a bug.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfall 4: trusted proxies are now opt-in (2026.8)
&lt;/h2&gt;

&lt;p&gt;2026.8 tightened the default forwarded-header handling. Previously authentik&lt;br&gt;
trusted all the private ranges; now it only trusts what you list explicitly.&lt;br&gt;
Behind a Synology reverse proxy forwarding to &lt;code&gt;localhost&lt;/code&gt;, that means:&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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skip this and the proxy headers get rejected, which surfaces as auth failures&lt;br&gt;
that look like almost anything except what they actually are.&lt;/p&gt;
&lt;h2&gt;
  
  
  Pitfall 5: the one that broke SSO — authorization_flow vs authentication_flow
&lt;/h2&gt;

&lt;p&gt;This was the expensive mistake, and it's the kind of thing that's easy to get&lt;br&gt;
wrong if you're doing per-application branding.&lt;/p&gt;

&lt;p&gt;A provider in authentik has &lt;strong&gt;two&lt;/strong&gt; flow fields, and they mean different things:&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;# "Flow used for authentication when the associated application is
#  accessed by an un-authenticated user."      ← the LOGIN page
&lt;/span&gt;&lt;span class="n"&gt;authentication_flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;

&lt;span class="c1"&gt;# "Flow used when authorizing this provider."  ← the OAuth consent page
&lt;/span&gt;&lt;span class="n"&gt;authorization_flow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;authentication_flow&lt;/code&gt; is the &lt;strong&gt;login page&lt;/strong&gt;. This is where you put a
per-application flow to customise the title and background.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;authorization_flow&lt;/code&gt; is the &lt;strong&gt;consent/authorize step&lt;/strong&gt; for a user who is
&lt;em&gt;already&lt;/em&gt; logged in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted different background images per app, so I created one flow per&lt;br&gt;
application and pointed the provider's &lt;code&gt;authorization_flow&lt;/code&gt; at it. Instant&lt;br&gt;
breakage: a user who was already authenticated still got walked through the&lt;br&gt;
full identification → password → MFA flow every time they opened another app.&lt;br&gt;
Single Sign-On was, effectively, gone.&lt;/p&gt;

&lt;p&gt;The fix was a single UPDATE to put the two fields back where they belong:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;authentik_core_provider&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;authorization_flow_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1d85b1b1-...'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- explicit-consent flow&lt;/span&gt;
    &lt;span class="n"&gt;authentication_flow_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'aa##-per-app-flow'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the branding itself — the per-app background and title — goes on the&lt;br&gt;
&lt;strong&gt;flow's own&lt;/strong&gt; &lt;code&gt;background&lt;/code&gt; and &lt;code&gt;title&lt;/code&gt; fields (writable since 2026.8), not on&lt;br&gt;
the brand's domain match.&lt;/p&gt;

&lt;p&gt;There was a second, related gotcha hiding behind this one. When I created the&lt;br&gt;
18 per-app flows, they all landed with &lt;code&gt;designation=authentication&lt;/code&gt;, and my&lt;br&gt;
&lt;code&gt;auth.hoelee.com&lt;/code&gt; brand had &lt;code&gt;flow_authentication&lt;/code&gt; set to &lt;code&gt;NULL&lt;/code&gt;. authentik's&lt;br&gt;
fallback when a brand has no explicit auth flow is to pick the first&lt;br&gt;
authentication flow &lt;strong&gt;by slug, alphabetically&lt;/strong&gt; — which happened to be&lt;br&gt;
&lt;code&gt;auth-agent&lt;/code&gt;, not &lt;code&gt;default-authentication-flow&lt;/code&gt;. So the root login page started&lt;br&gt;
showing my agent's background image. Setting the brand's &lt;code&gt;flow_authentication&lt;/code&gt;&lt;br&gt;
to the real default flow fixed it.&lt;/p&gt;

&lt;p&gt;And one more that compounds with the RBAC pitfall: creating a flow sets its&lt;br&gt;
&lt;code&gt;background&lt;/code&gt; but &lt;strong&gt;not its stages&lt;/strong&gt; — &lt;code&gt;stages&lt;/code&gt; is read-only on the flow object.&lt;br&gt;
An empty flow with no stage bindings is exactly what produced an infinite&lt;br&gt;
redirect loop on the login page earlier in the migration. Stage bindings are&lt;br&gt;
created separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/v3/flows/bindings/   # { target: "&amp;lt;flow pk&amp;gt;", stage: "&amp;lt;stage pk&amp;gt;", order: N }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;The whole ordeal came down to three preventable patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never guess at a field's semantics&lt;/strong&gt; — I treated &lt;code&gt;authorization_flow&lt;/code&gt; as
"the login flow" when the source-of-truth is the model definition, which
spells out the difference in the field's own docstring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the API surface at arm's length&lt;/strong&gt; — API tokens kept expiring mid-run
with every server restart, so I ended up doing the critical fixes directly
against the database with &lt;code&gt;psql&lt;/code&gt;. Reliable, but worth scripting &lt;em&gt;before&lt;/em&gt;
the cluster is on fire, not during.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One breaking change per restart&lt;/strong&gt; — I tried to reason about storage,
RBAC, and proxy changes all in one go. Each would have been trivial if
isolated and verified independently.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;authentik now runs &lt;strong&gt;2026.8.1&lt;/strong&gt; — current, patched, all containers healthy —&lt;br&gt;
with 18 applications each showing their own background and title on the login&lt;br&gt;
page, and SSO working across every subdomain. Seventeen of the applications I&lt;br&gt;
use daily went from "asks me to log in again every time I switch apps" back to&lt;br&gt;
"log in once, move freely."&lt;/p&gt;

&lt;p&gt;The lesson worth carrying: a major-version upgrade on auth infrastructure is&lt;br&gt;
roughly 10% "change the image tag" and 90% "the data model, storage layout,&lt;br&gt;
and proxy rules all shifted underneath you." Back up, go one version at a time,&lt;br&gt;
and when something behaves in a way that makes no sense, read the field name&lt;br&gt;
again before you reach for another config.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want single sign-on for your business?
&lt;/h2&gt;

&lt;p&gt;If you're running several internal apps — a dashboard, an email server, a help&lt;br&gt;
desk, a file server — and your team is still logging into each one separately&lt;br&gt;
(or reusing the same password everywhere), I set up and maintain exactly this&lt;br&gt;
kind of infrastructure. I'll deploy authentik, wire it to your existing apps,&lt;br&gt;
add MFA, and make sure "log in once" actually works — then leave you with a&lt;br&gt;
handover so you're never locked in.&lt;/p&gt;

&lt;p&gt;Reach me at &lt;a href="mailto:me@hoelee.com"&gt;me@hoelee.com&lt;/a&gt; or WhatsApp&lt;br&gt;
&lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;, or see what I do at&lt;br&gt;
&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>docker</category>
    </item>
    <item>
      <title>Upgrading n8n v1 to v2: Seven Deprecations in One Log File</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sun, 08 Nov 2026 04:25:21 +0000</pubDate>
      <link>https://dev.to/hoelee/upgrading-n8n-v1-to-v2-seven-deprecations-in-one-log-file-4920</link>
      <guid>https://dev.to/hoelee/upgrading-n8n-v1-to-v2-seven-deprecations-in-one-log-file-4920</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/n8n-v1-to-v2-upgrade-gotchas/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I run n8n as the automation backbone for my self-hosted stack — it handles&lt;br&gt;
file-delivery permissions, database backups, and a text-to-speech API that a&lt;br&gt;
reading app depends on. It had been sitting on the &lt;code&gt;1.123.x&lt;/code&gt; line for the&lt;br&gt;
better part of a year, quietly doing its job.&lt;/p&gt;

&lt;p&gt;Then I pulled &lt;code&gt;n8nio/n8n:2.40.1&lt;/code&gt; and restarted the container. The upgrade&lt;br&gt;
itself took about ninety seconds. Understanding what it &lt;em&gt;broke&lt;/em&gt; took the rest&lt;br&gt;
of the evening — and almost all of it was already written down in a single log&lt;br&gt;
file that n8n prints on boot. I just hadn't read it carefully enough the first&lt;br&gt;
time.&lt;/p&gt;

&lt;p&gt;This is that log file, decoded, so you can plan your own v1 → v2 jump instead&lt;br&gt;
of discovering these at 11 PM.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;Major-version upgrades of an automation platform are different from upgrading&lt;br&gt;
a leaf service. n8n is &lt;em&gt;the thing that runs everything else&lt;/em&gt;: if it comes up&lt;br&gt;
broken, your backups, your access-control syncs, and your internal APIs all&lt;br&gt;
stop with it. Worse, most of what breaks in v2 doesn't throw an error — it&lt;br&gt;
logs a deprecation notice once and then quietly does something different.&lt;/p&gt;

&lt;p&gt;The seven items below are the ones that actually applied to a real, messy,&lt;br&gt;
production-shaped install. Three of them changed behavior in my stack. One of&lt;br&gt;
them silently turned a feature &lt;em&gt;off&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Start here: n8n tells you what's wrong at boot
&lt;/h2&gt;

&lt;p&gt;Before touching a single workflow, read the container log from the top. On a&lt;br&gt;
fresh v2 boot, n8n prints an explicit deprecation block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;There are deprecations related to your n8n setup. Please take the recommended
actions to update your configuration:
 - WEBHOOK_URL -&amp;gt; Use N8N_WEBHOOK_URL instead, which sets the base URL for
   both test and production webhooks.
 - N8N_UNVERIFIED_PACKAGES_ENABLED -&amp;gt; The default for this variable will
   change to `false` in a future version.
 - N8N_RUNNERS_MODE -&amp;gt; Internal task runner mode is deprecated and will be
   removed in a future version.
 - N8N_RUNNERS_TASK_TIMEOUT -&amp;gt; The default for this variable will be reduced
   from 300 (5 minutes) to 60 (1 minute) in a future version.
 - N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -&amp;gt; The default will be
   reduced from 2 GiB to 256 MiB in a future version.
 - N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -&amp;gt; The default will be reduced from
   5000 to 1000 in a future version.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That block is the core of your migration checklist. Five of my seven configuration and deprecation gotchas are right inside it, and the other two surfaced a few lines away in the same startup log.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your env values can now fail &lt;em&gt;schema validation&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;Here is the one that confused me most, because it looked like a nonsense error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telemetry event "Instance started" failed schema validation:
execution_variables.executions_data_save_on_error: Invalid option:
expected one of "all"|"none"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had &lt;code&gt;EXECUTIONS_DATA_SAVE_ON_ERROR=error&lt;/code&gt; set — a value that was perfectly&lt;br&gt;
legal in v1, and that I'd chosen deliberately, because saving &lt;em&gt;only&lt;/em&gt; failed&lt;br&gt;
executions is the sane default for a busy instance. In v2 that value is no&lt;br&gt;
longer in the allowed set, which is now &lt;code&gt;all&lt;/code&gt; or &lt;code&gt;none&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The failure mode is the interesting part. It didn't crash. It didn't even warn&lt;br&gt;
in a way that reads like an error at a glance — it emitted a &lt;em&gt;telemetry schema&lt;br&gt;
validation&lt;/em&gt; message, which sounds like an n8n-internal problem, not a&lt;br&gt;
configuration problem of mine. The setting was effectively ignored.&lt;/p&gt;

&lt;p&gt;The fix is to move that intent somewhere coherent: pick a legal value and&lt;br&gt;
control volume with pruning instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXECUTIONS_DATA_SAVE_ON_ERROR=all
EXECUTIONS_DATA_SAVE_ON_SUCCESS=none
EXECUTIONS_DATA_PRUNE=true
EXECUTIONS_DATA_MAX_AGE=336
EXECUTIONS_DATA_PRUNE_MAX_COUNT=10000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; in v2, treat your environment variables as a typed interface with&lt;br&gt;
a schema. An invalid value may be dropped silently rather than rejected loudly.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. &lt;code&gt;WEBHOOK_URL&lt;/code&gt; is deprecated for &lt;code&gt;N8N_WEBHOOK_URL&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you publish webhooks behind a reverse proxy — which you almost certainly do,&lt;br&gt;
because that's how they become reachable — the base URL variable is load-bearing.&lt;br&gt;
It's what makes n8n report the &lt;em&gt;public&lt;/em&gt; webhook path instead of&lt;br&gt;
&lt;code&gt;http://localhost:5678/...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The old name still works today, so this one won't bite immediately. But note&lt;br&gt;
the wording: the new variable sets the base URL for &lt;strong&gt;both test and production&lt;br&gt;
webhooks&lt;/strong&gt;. In my setup those had drifted apart in behavior, which is exactly&lt;br&gt;
the class of bug this consolidation is meant to eliminate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# before (still functional, deprecated)
WEBHOOK_URL=https://auto.example.com/

# after
N8N_WEBHOOK_URL=https://auto.example.com/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Internal task runner mode is going away — and mine was already broken
&lt;/h2&gt;

&lt;p&gt;This one was sitting in my logs the whole time, several lines above the&lt;br&gt;
deprecation block, and I'd been reading past it for months:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to start Python task runner in internal mode. because Python 3 is
missing from this system. Launching a Python runner in internal mode is
intended only for debugging and is not recommended for production.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any workflow of yours uses a &lt;strong&gt;Python&lt;/strong&gt; Code node, it has not been running&lt;br&gt;
in internal mode at all — there's no Python in the stock image. JavaScript&lt;br&gt;
Code nodes are fine (a JS runner registers normally), which is why this can go&lt;br&gt;
unnoticed indefinitely: everything &lt;em&gt;looks&lt;/em&gt; healthy.&lt;/p&gt;

&lt;p&gt;v2 makes the direction of travel explicit: switch to &lt;code&gt;external&lt;/code&gt; mode and share&lt;br&gt;
an auth token with a separate launcher process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N8N_RUNNERS_MODE=external
N8N_RUNNERS_AUTH_TOKEN=&amp;lt;a long random string&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Note: switching to &lt;code&gt;external&lt;/code&gt; mode means pairing the main container with the &lt;code&gt;n8nio/runners&lt;/code&gt; sidecar image in your compose stack sharing that same token, which actually ships both the JS and Python runtimes.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; "internal mode is deprecated" is the headline, but the real&lt;br&gt;
finding is that a runner type can be &lt;em&gt;silently non-functional&lt;/em&gt; for months.&lt;br&gt;
Check &lt;code&gt;docker logs&lt;/code&gt; for the runner registration line, not just for up/down.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Task timeout drops from 300s to 60s
&lt;/h2&gt;

&lt;p&gt;This is the one I'd flag hardest for anyone with slow workflows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N8N_RUNNERS_TASK_TIMEOUT -&amp;gt; The default for this variable will be reduced
from 300 (5 minutes) to 60 (1 minute) in a future version.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Be honest about your own workloads. Do you have a Code node that loops over&lt;br&gt;
thousands of records, or an HTTP call to a slow upstream? Mine do — a nightly&lt;br&gt;
reconciliation walks every customer and calls an external API per record. On a&lt;br&gt;
future upgrade, that stops at sixty seconds with no config change on my side.&lt;/p&gt;

&lt;p&gt;Set it explicitly now, while you're already in the file:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;The general principle for every deprecation of the form "the default will&lt;br&gt;
change": &lt;strong&gt;if you rely on the current default, pin it explicitly.&lt;/strong&gt; Otherwise&lt;br&gt;
the upgrade is a silent behavior change, and you'll debug it as a bug rather&lt;br&gt;
than recognise it as a stale default.&lt;/p&gt;
&lt;h2&gt;
  
  
  5 &amp;amp; 6. Two compression-node limits shrink (2 GiB → 256 MiB, 5000 → 1000 entries)
&lt;/h2&gt;

&lt;p&gt;These two travel together and matter only if you use compression/decompression&lt;br&gt;
nodes on large payloads — which is easy to end up doing when you're shuttling&lt;br&gt;
database dumps or archives through a workflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES -&amp;gt; reduced from 2 GiB to
256 MiB in a future version.
N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES -&amp;gt; reduced from 5000 to 1000 in a
future version.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An eighth of the memory ceiling and a fifth of the entry limit. Nothing errors;&lt;br&gt;
the node just refuses at a threshold you didn't set. Pin both if you're near&lt;br&gt;
either.&lt;/p&gt;
&lt;h2&gt;
  
  
  7. The storage path renames in v3 — and you have a volume mounted at the old one
&lt;/h2&gt;

&lt;p&gt;Not a v2 breakage, but v2 warns about it, and it's the one with real data-planning&lt;br&gt;
implications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Deprecation warning: The storage directory "/home/node/.n8n/binaryData" will
be renamed to "/home/node/.n8n/storage" in n8n v3. To migrate now, set
N8N_MIGRATE_FS_STORAGE_PATH=true. If you have a volume mounted at the old
path, update your mount configuration after migration.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that last sentence again: &lt;em&gt;if you have a volume mounted at the old path,&lt;br&gt;
update your mount configuration after migration.&lt;/em&gt; If you set the migration flag&lt;br&gt;
and keep your old bind mount, you now have two directories and your binary data&lt;br&gt;
lives in whichever one the container is actually pointed at. Do the rename and&lt;br&gt;
the mount change in the same maintenance window — not one now, one "later".&lt;/p&gt;
&lt;h2&gt;
  
  
  The one outside the deprecation block: my AI sandbox disabled itself
&lt;/h2&gt;

&lt;p&gt;Here's the finding that had nothing to do with a deprecation notice, and that&lt;br&gt;
I'd never have caught without reading the full boot sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sandbox: enabled=false provider=n8n-sandbox (DB override; env was enabled=true
provider=n8n-sandbox)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My environment said enabled. The database said otherwise. &lt;strong&gt;The database won.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The env var was &lt;code&gt;N8N_INSTANCE_AI_SANDBOX_ENABLED=true&lt;/code&gt;, and it was still set&lt;br&gt;
correctly on the container. But a value persisted in n8n's own configuration&lt;br&gt;
store overrode it at startup — and the only place that conflict is reported is&lt;br&gt;
inside a parenthetical in a log line.&lt;/p&gt;

&lt;p&gt;This is a genuinely valuable debugging lesson beyond n8n: when a feature is off&lt;br&gt;
despite the env var being obviously right, suspect a &lt;strong&gt;persisted settings layer&lt;br&gt;
that outranks your environment&lt;/strong&gt;. The container config is not always the last&lt;br&gt;
word. Grep for the feature name in the logs, and don't stop at the env value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the boot log before declaring the upgrade done.&lt;/strong&gt; Every deprecation
that mattered to me was printed on startup, in one block, on the first run.
My v1 habit was to check "is it up, do the workflows run" — which is exactly
the check that misses all seven of these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "the default will change" as a to-do, not a warning.&lt;/strong&gt; Five of the
seven items are future-default changes. Pinning them now costs one edit and
converts a mystery outage later into a config diff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff the container config against n8n's own stored config.&lt;/strong&gt; The sandbox
override taught me that env is one of two inputs, not the source of truth.
When behavior and configuration disagree, believe the behavior and go find
the higher-priority layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the image tag and keep the previous one.&lt;/strong&gt; I jumped v1 → &lt;code&gt;2.40.1&lt;/code&gt;
directly. Having the old image on disk is what makes a rollback a
&lt;code&gt;docker run&lt;/code&gt; instead of a rebuild.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;n8n is on 2.40.1 with the full deprecation block resolved: the schema-invalid&lt;br&gt;
value corrected, &lt;code&gt;N8N_WEBHOOK_URL&lt;/code&gt; in place, the JS runner registering cleanly,&lt;br&gt;
and the task timeout and compression limits pinned so the next upgrade is a&lt;br&gt;
no-op rather than a surprise.&lt;/p&gt;

&lt;p&gt;Everything downstream kept working — the file-permission syncs, the nightly&lt;br&gt;
backups, the text-to-speech endpoint. That's the outcome worth aiming for with&lt;br&gt;
an upgrade like this: not "it came back up", but "it came back up &lt;em&gt;and&lt;/em&gt; the&lt;br&gt;
next three upgrades are already paid for".&lt;/p&gt;

&lt;p&gt;The uncomfortable part is how much of it I could have known in advance. n8n&lt;br&gt;
handed me the entire list, unprompted, at startup. The upgrade was never the&lt;br&gt;
hard part — reading the output was.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running automation you'd rather not babysit?
&lt;/h2&gt;

&lt;p&gt;I build and maintain self-hosted automation — n8n workflows, Docker stacks,&lt;br&gt;
and the glue between apps that were never designed to talk to each other. If&lt;br&gt;
you're facing a major version upgrade, or you have automation that works until&lt;br&gt;
it doesn't, I plan the migration, do it in a maintenance window, and document&lt;br&gt;
every config decision so the next upgrade is boring.&lt;/p&gt;

&lt;p&gt;Reach me at &lt;a href="mailto:me@hoelee.com?subject=n8n%20upgrade"&gt;me@hoelee.com&lt;/a&gt; or&lt;br&gt;
WhatsApp &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;+60 12-797 2969&lt;/a&gt;, or see what I do at&lt;br&gt;
&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>docker</category>
    </item>
    <item>
      <title>Upgrading CodeIgniter 4.6 to 4.7: The Breaking Changes the Guide Misses</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Sat, 07 Nov 2026 04:25:54 +0000</pubDate>
      <link>https://dev.to/hoelee/upgrading-codeigniter-46-to-47-the-breaking-changes-the-guide-misses-3gf8</link>
      <guid>https://dev.to/hoelee/upgrading-codeigniter-46-to-47-the-breaking-changes-the-guide-misses-3gf8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/upgrading-codeigniter-46-to-47/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I upgraded a CodeIgniter 4 app from 4.6.3 to 4.7.4. I read the upgrade guide, checked every documented breaking change against the codebase, and confirmed none of them applied. Then I ran the app and it fataled twice.&lt;/p&gt;

&lt;p&gt;Both fatal errors were the same kind of problem, and that kind of problem is &lt;strong&gt;not mentioned anywhere in the upgrade guide&lt;/strong&gt;. This is a short post about what actually breaks and why the guide can't warn you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters
&lt;/h2&gt;

&lt;p&gt;Composer has a scope boundary that is easy to forget. &lt;code&gt;composer update&lt;/code&gt; writes to &lt;code&gt;vendor/&lt;/code&gt; — the &lt;strong&gt;system scope&lt;/strong&gt;, the framework's own files, which it owns and can replace freely.&lt;/p&gt;

&lt;p&gt;Your &lt;code&gt;app/Config/*.php&lt;/code&gt; files are &lt;strong&gt;project scope&lt;/strong&gt;. They are yours. Composer will never touch them, and the upgrade guide describes them as "you may want to merge these changes" rather than "this will break".&lt;/p&gt;

&lt;p&gt;But when 4.7's framework code reads a property that your 4.6-era config class does not define, you get a fatal — not a deprecation, not a warning. The guide cannot enumerate this, because the set of properties is the intersection of &lt;em&gt;what the new framework reads&lt;/em&gt; and &lt;em&gt;what your config file happens to contain&lt;/em&gt;, and it has no visibility into the second half.&lt;/p&gt;

&lt;p&gt;If you are upgrading a CI4 app more than a minor version behind, budget for this. It took me about an hour and 14 config files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I checked first
&lt;/h2&gt;

&lt;p&gt;The documented breaking changes in 4.7.0, and whether each applied:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Documented change&lt;/th&gt;
&lt;th&gt;Affects this app?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Model::insertBatch()&lt;/code&gt; / &lt;code&gt;updateBatch()&lt;/code&gt; return values&lt;/td&gt;
&lt;td&gt;No — no Models used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entity casting changes&lt;/td&gt;
&lt;td&gt;No — no Entities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation rule changes (&lt;code&gt;regex_match&lt;/code&gt;, &lt;code&gt;differs&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Encryption handler defaults&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploaded-file / image validation behaviour&lt;/td&gt;
&lt;td&gt;No — no uploads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;IncomingRequest&lt;/code&gt; internal changes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Removed &lt;code&gt;Session&lt;/code&gt; class properties&lt;/td&gt;
&lt;td&gt;No — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PageCache&lt;/code&gt; constructor signature&lt;/td&gt;
&lt;td&gt;No — see below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two of those needed a closer look, and both turned out to be &lt;strong&gt;false alarms&lt;/strong&gt; worth describing, because they look identical to a real hit if you only grep:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Removed &lt;code&gt;Session&lt;/code&gt; class properties"&lt;/strong&gt; — grepping for &lt;code&gt;session&lt;/code&gt; in my configs lit up immediately. But the properties in question (&lt;code&gt;sessionDriver&lt;/code&gt;, &lt;code&gt;sessionCookieName&lt;/code&gt;, and friends) on my &lt;code&gt;Config\App&lt;/code&gt; are &lt;strong&gt;&lt;code&gt;Config\App&lt;/code&gt;'s own properties&lt;/strong&gt;, which were not removed. The guide refers to properties on the &lt;code&gt;Session&lt;/code&gt; class. Same word, different class.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"&lt;code&gt;PageCache&lt;/code&gt; constructor signature changed"&lt;/strong&gt; — I reference &lt;code&gt;pagecache&lt;/code&gt; — but only as a filter alias in &lt;code&gt;Config\Filters&lt;/code&gt;. That does not extend or instantiate the class, which is the only thing that breaks. A reference is not a subclass.&lt;/p&gt;

&lt;p&gt;Both checks taught me the same thing: &lt;strong&gt;grep finds &lt;em&gt;mentions&lt;/em&gt;, not &lt;em&gt;usages&lt;/em&gt;.&lt;/strong&gt; Verify the class in the match is the class the guide means, and that you actually extend or call the thing that changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fatals that were not in the guide
&lt;/h2&gt;

&lt;p&gt;Both surfaced on a clean boot. Both are properties that 4.7 framework code reads and 4.6-era config files do not define.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;Config\App::$permittedURIChars&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Undefined property: Config\App::$permittedURIChars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Required by 4.7's Router, which validates incoming URI characters against this property. Absent from a 4.6-era &lt;code&gt;App.php&lt;/code&gt;, the Router throws before it routes anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Config/App.php — append to the class&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * CI4 4.7 compatibility: allowed characters in a URI.
 * Required by the 4.7 Router.
 */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$permittedURIChars&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'a-z 0-9~%.:_\-'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. &lt;code&gt;Config\Format::$jsonEncodeDepth&lt;/code&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Undefined property: Config\Format::$jsonEncodeDepth
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Required by 4.7's &lt;code&gt;JSONFormatter&lt;/code&gt;. This one broke the app's &lt;strong&gt;report engine&lt;/strong&gt;, because the engine's JSON response is decoded with &lt;code&gt;json_decode()&lt;/code&gt; and the failure surfaced much further downstream as a confusing parse error rather than the real "undefined property" message.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/Config/Format.php — append to the class&lt;/span&gt;
&lt;span class="cd"&gt;/**
 * CI4 4.7 compatibility: json_encode() depth limit.
 */&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$jsonEncodeDepth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second one is worth dwelling on. The visible error was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to parse JSON string. Malformed UTF-8 characters
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which points at encoding. The actual cause was a missing config property. &lt;strong&gt;If a framework error names a symptom rather than a cause, go looking for the real exception in the log before acting on the message you were given&lt;/strong&gt; — I lost time chasing UTF-8 that was never broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find the rest before they bite you
&lt;/h2&gt;

&lt;p&gt;Both fatal errors were found by &lt;em&gt;running&lt;/em&gt; the app. Running is the reliable method, but you can get ahead of it by diffing property coverage.&lt;/p&gt;

&lt;p&gt;The approach: parse the framework's shipped config defaults from &lt;code&gt;vendor/&lt;/code&gt;, parse your project's config classes, and list properties defined in the former but missing from the latter. That gives you the whole class of problem at once instead of one fatal per restart.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;

&lt;span class="n"&gt;sys_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;vendor/codeigniter4/framework/system/Config&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;app/Config&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;FileNotFoundError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# public/protected/private $name = ...
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(?:public|protected|private)\s+(?:[\w\\\[\]|?]+\s+)?\$(\w+)&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;# names whose properties 4.7 requires but a stale config may omit
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;App&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;Cache&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;ContentSecurityPolicy&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;CURLRequest&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;DocTypes&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;Email&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;Encryption&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;Exceptions&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;Format&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;Honeypot&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;Migrations&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;Paths&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;Routing&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;Toolbar&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;View&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys_&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.php&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;props&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.php&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it against the &lt;strong&gt;new&lt;/strong&gt; &lt;code&gt;vendor/&lt;/code&gt; after &lt;code&gt;composer update&lt;/code&gt; and before you boot the app. Anything it prints is a candidate fatal.&lt;/p&gt;

&lt;p&gt;In this project it flagged 14 config files. I merged the missing properties into each one, appended in a marked block at the end of the class so existing settings and behaviour were untouched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;    &lt;span class="c1"&gt;// ----------------------------------------------------------------&lt;/span&gt;
    &lt;span class="c1"&gt;// CI4 4.7 compatibility — properties the 4.7 framework reads.&lt;/span&gt;
    &lt;span class="c1"&gt;// Appended as a block so existing settings above are unchanged.&lt;/span&gt;
    &lt;span class="c1"&gt;// ----------------------------------------------------------------&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two config files the framework now ships had no counterpart at all and needed creating rather than merging: &lt;code&gt;Hostnames.php&lt;/code&gt; and &lt;code&gt;WorkerMode.php&lt;/code&gt;. Copy those straight from &lt;code&gt;vendor/codeigniter4/framework/app/Config/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also worth knowing before you upgrade
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Validate &lt;code&gt;app.baseURL&lt;/code&gt; properly.&lt;/strong&gt; 4.7 throws a &lt;code&gt;ConfigException&lt;/code&gt; on values 4.6 accepted. &lt;code&gt;'http://localhost/'&lt;/code&gt; — which had been sitting in my local &lt;code&gt;.env&lt;/code&gt; for months, working fine — now throws. Production was unaffected because it used a real URL, but any dev environment with a shorthand baseURL will refuse to boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The security case is real.&lt;/strong&gt; 4.7.4 alone patched an uploaded-file extension bypass (&lt;code&gt;is_image&lt;/code&gt; / &lt;code&gt;mime_in&lt;/code&gt;), a SQL injection in &lt;code&gt;deleteBatch()&lt;/code&gt;, and a path traversal in &lt;code&gt;UploadedFile::move()&lt;/code&gt;. None were exploitable in this app — it does no uploads and uses no database — but all three become live the moment those features are added. Upgrading before you need those features is the cheaper order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PHP floor is now 8.2.&lt;/strong&gt; Check your host before starting, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run the app against the new &lt;code&gt;vendor/&lt;/code&gt; before doing anything else.&lt;/strong&gt; &lt;code&gt;composer update&lt;/code&gt;, then boot it, then fix what breaks. Reading the guide tells you what &lt;em&gt;might&lt;/em&gt; change; only running tells you what &lt;em&gt;did&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do the property diff as a matter of course.&lt;/strong&gt; It converts a serial debugging session into one report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do the upgrade as its own commit, on its own.&lt;/strong&gt; I did, and it made the two fatals trivially bisectable — one &lt;code&gt;git show&lt;/code&gt; told me exactly which change introduced each. Mixing a framework bump into a feature branch turns a five-minute diagnosis into an archaeology exercise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read the changelog, not just the upgrade guide.&lt;/strong&gt; The guide lists what the maintainers judged &lt;em&gt;likely&lt;/em&gt; to break you. The changelog lists what changed. They are not the same set, and the gap is exactly where these two fatals lived.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;4.6.3 → 4.7.4 on a 17,000-line CodeIgniter app: 14 config files merged, two new config files added, two fatals found and fixed, and every route verified returning 200 — including a real report generation producing 19 A4 pages with byte-comparable output to the pre-upgrade baseline. Zero errors in the application log.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Running an older CodeIgniter app?&lt;/strong&gt; I do framework upgrades like this one — CI4 minor bumps, PHP version migrations, and the config-merging work that the upgrade guide assumes you will figure out yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp +60 12-797 2969&lt;/a&gt; · &lt;a href="mailto:me@hoelee.com?subject=CodeIgniter%20upgrade"&gt;me@hoelee.com&lt;/a&gt; · &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>php</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>programming</category>
    </item>
    <item>
      <title>The JPA Field That Turns a Silent Lost Update Into a 409</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Fri, 06 Nov 2026 04:25:50 +0000</pubDate>
      <link>https://dev.to/hoelee/the-jpa-field-that-turns-a-silent-lost-update-into-a-409-58ib</link>
      <guid>https://dev.to/hoelee/the-jpa-field-that-turns-a-silent-lost-update-into-a-409-58ib</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/jpa-version-field-lost-update/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two people open the same post. Both edit it. Both save. One of the saves&lt;br&gt;
disappears without a trace — no error, no warning, and the API answered&lt;br&gt;
HTTP 200 for both of them.&lt;/p&gt;

&lt;p&gt;That is the lost update problem, and it is the quietest data-loss bug in&lt;br&gt;
read-modify-write APIs. I hit it while building my Spring Boot reference&lt;br&gt;
service — a Spring Boot 3.5.16 (Java 21) REST API where editors create and&lt;br&gt;
update posts from a browser page. The fix turned out to be one &lt;code&gt;@Version&lt;/code&gt;&lt;br&gt;
field on the JPA entity, one field on the request contract, and one&lt;br&gt;
exception that the API layer maps to HTTP 409 Conflict. This post traces&lt;br&gt;
that path through the actual code.&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem: an edit that vanishes with no one to blame
&lt;/h2&gt;

&lt;p&gt;Why does one editor's change disappear when two people save the same&lt;br&gt;
record?&lt;/p&gt;

&lt;p&gt;Follow the timeline. Editor A and editor B both fetch the same post.&lt;br&gt;
A saves first: the row is updated and the API answers 200. B saves a&lt;br&gt;
moment later: the row is updated &lt;em&gt;again&lt;/em&gt;, this time over A's text, and&lt;br&gt;
the API answers 200 again. Every request succeeded; every response told&lt;br&gt;
its caller that their save is the state of the record. A's edit is&lt;br&gt;
simply gone, and neither client has any way to know.&lt;/p&gt;

&lt;p&gt;The failure is silent because it is normal read-modify-write behaviour,&lt;br&gt;
not an anomaly. That is exactly why the bug survives in real systems, and&lt;br&gt;
why it is worth fixing properly in something meant to demonstrate&lt;br&gt;
engineering judgement.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I tried first, and why it failed
&lt;/h2&gt;

&lt;p&gt;My first sketch of the update flow was the obvious one: &lt;code&gt;GET&lt;/code&gt; the record,&lt;br&gt;
edit it, &lt;code&gt;PUT&lt;/code&gt; it back with the new title and body and nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET  /api/posts/{id}    # read the record
PUT  /api/posts/{id}    # write it back with a new title and body
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server loads the row, applies the update, and returns 200. It has no&lt;br&gt;
way to notice that the row moved underneath the client between the &lt;code&gt;GET&lt;/code&gt;&lt;br&gt;
and the &lt;code&gt;PUT&lt;/code&gt; — the read and the write are two unrelated requests, and&lt;br&gt;
nothing in the contract connects them. The last write wins, and both&lt;br&gt;
writers are told they succeeded.&lt;/p&gt;

&lt;p&gt;A 200 is worse than an error here. An error at least tells the losing&lt;br&gt;
client that something happened and gives them a reason to look. A 200&lt;br&gt;
tells both clients that their save is the current state of the record,&lt;br&gt;
which is a lie for one of them. The losing edit now exists nowhere — not&lt;br&gt;
on screen, not in the database — and because the client believes it&lt;br&gt;
saved, nobody goes looking for the data.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix: a &lt;a class="mentioned-user" href="https://dev.to/version"&gt;@version&lt;/a&gt; field, a contract, and a conflict
&lt;/h2&gt;

&lt;p&gt;The fix is five small parts, all of them in the reference service's source.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. The entity carries a version column
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Entity&lt;/span&gt;
&lt;span class="nd"&gt;@Table&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"posts"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;@Index&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"idx_posts_title"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;columnList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
&lt;span class="nd"&gt;@EntityListeners&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditingEntityListener&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="nd"&gt;@Id&lt;/span&gt;
    &lt;span class="nd"&gt;@GeneratedValue&lt;/span&gt;
    &lt;span class="nd"&gt;@UuidGenerator&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Column&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

    &lt;span class="nd"&gt;@Version&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;@Version&lt;/code&gt; tells Hibernate to treat &lt;code&gt;version&lt;/code&gt; as an optimistic lock. Every&lt;br&gt;
&lt;code&gt;UPDATE&lt;/code&gt; it generates becomes conditional:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the row's version no longer matches the value the statement was built&lt;br&gt;
with, zero rows change and Hibernate rejects the flush instead of quietly&lt;br&gt;
overwriting. The entity never bumps the counter by hand — the update&lt;br&gt;
method only touches &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;; the version moves with the write&lt;br&gt;
itself.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. The version is part of the API contract
&lt;/h3&gt;

&lt;p&gt;A lock is useless if the client cannot take part in it, so the version&lt;br&gt;
travels through the API in both directions. The response record exposes&lt;br&gt;
it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;PostResponse&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;authorId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
                           &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;createdAt&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt; &lt;span class="n"&gt;updatedAt&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the update request requires it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;UpdatePostRequest&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
        &lt;span class="nd"&gt;@NotBlank&lt;/span&gt; &lt;span class="nd"&gt;@Size&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;160&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@NotBlank&lt;/span&gt; &lt;span class="nd"&gt;@Size&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;
        &lt;span class="nd"&gt;@NotNull&lt;/span&gt; &lt;span class="nd"&gt;@Min&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The learning note next to &lt;code&gt;UpdatePostRequest&lt;/code&gt; says it plainly: "the&lt;br&gt;
expected version is part of the update contract, making optimistic&lt;br&gt;
locking visible to clients."&lt;/p&gt;
&lt;h3&gt;
  
  
  3. The service checks it inside the transaction that owns the write
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@CachePut&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cacheNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"posts"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"#postId"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;PostResponse&lt;/span&gt; &lt;span class="nf"&gt;updatePost&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;UpdatePostRequest&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;postRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;findById&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orElseThrow&lt;/span&gt;&lt;span class="o"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PostNotFoundException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getVersion&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;PostVersionConflictException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;trim&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;PostResponse&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;saveAndFlush&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The load, the comparison and the save all happen inside one&lt;br&gt;
&lt;code&gt;@Transactional&lt;/code&gt; method, and that is the part that makes the check&lt;br&gt;
honest: the version is compared against the row as it stands &lt;em&gt;at write&lt;br&gt;
time&lt;/em&gt;, not against a snapshot from an earlier request. If the read and&lt;br&gt;
the write lived in different transactions, the check would compare&lt;br&gt;
against a version the row has already left behind, and the whole scheme&lt;br&gt;
would be theatre. The learning note on &lt;code&gt;PostService&lt;/code&gt; names the rule:&lt;br&gt;
"service methods centralize transaction boundaries, cache coherence, and&lt;br&gt;
optimistic-locking rules." The service also runs with&lt;br&gt;
&lt;code&gt;spring.jpa.open-in-view: false&lt;/code&gt;, so no lingering session serves a stale&lt;br&gt;
entity across requests — the row is re-read inside the writing&lt;br&gt;
transaction.&lt;/p&gt;

&lt;p&gt;The explicit check catches stale requests deterministically. The&lt;br&gt;
&lt;code&gt;@Version&lt;/code&gt; column still matters underneath: if another commit slips in&lt;br&gt;
between the check and the flush, the conditional &lt;code&gt;UPDATE&lt;/code&gt; affects zero&lt;br&gt;
rows and Hibernate refuses the write anyway.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. The handler maps the exception to a 409
&lt;/h3&gt;

&lt;p&gt;The exception itself carries the message a client can act on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostVersionConflictException&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nf"&gt;PostVersionConflictException&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;UUID&lt;/span&gt; &lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;super&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Post %s has changed; fetch it again before retrying"&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;formatted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;postId&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the error boundary turns it into the right HTTP answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@ExceptionHandler&lt;/span&gt;&lt;span class="o"&gt;({&lt;/span&gt;&lt;span class="nc"&gt;PostVersionConflictException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ObjectOptimisticLockingFailureException&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;})&lt;/span&gt;
&lt;span class="nc"&gt;ProblemDetail&lt;/span&gt; &lt;span class="nf"&gt;handleConflict&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RuntimeException&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;problem&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpStatus&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;CONFLICT&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"POST_VERSION_CONFLICT"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;HttpStatus.CONFLICT&lt;/code&gt; is HTTP 409, and the response is a problem document&lt;br&gt;
— a &lt;code&gt;type&lt;/code&gt; URI, a stable machine code (&lt;code&gt;POST_VERSION_CONFLICT&lt;/code&gt;) and a&lt;br&gt;
human-readable detail. Mapping both our explicit exception and Spring Data JPA's &lt;code&gt;ObjectOptimisticLockingFailureException&lt;/code&gt; (thrown when Hibernate detects a race condition) guarantees that concurrent write collisions always return 409 rather than an unhandled 500. The service enables the framework's problem-details&lt;br&gt;
support in its configuration (&lt;code&gt;spring.mvc.problemdetails.enabled: true&lt;/code&gt;),&lt;br&gt;
so validation and every other error ride the same shape.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The whole path, end to end
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;stale PUT /api/posts/{id}
  -&amp;gt; PostService.updatePost re-reads the row inside its transaction
  -&amp;gt; post.getVersion() != request.version()
  -&amp;gt; PostVersionConflictException: "fetch it again before retrying"
  -&amp;gt; ApiExceptionHandler.handleConflict
  -&amp;gt; HTTP 409, ProblemDetail with code POST_VERSION_CONFLICT
  -&amp;gt; the client knows its premise was stale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;@Version&lt;/code&gt; field, one &lt;code&gt;version&lt;/code&gt; in the request contract, one&lt;br&gt;
exception, one handler method. Nothing on the controller changed —&lt;br&gt;
&lt;code&gt;updatePost&lt;/code&gt; still looks like an ordinary &lt;code&gt;PUT&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a conflict is the correct answer, not a retry
&lt;/h2&gt;

&lt;p&gt;When two editors produce two different versions of the same record, the&lt;br&gt;
server cannot know which intent is the one meant. Both saves are&lt;br&gt;
legitimate from their authors' point of view, and replaying the stale&lt;br&gt;
write just replays the same data loss.&lt;/p&gt;

&lt;p&gt;The honest verdict is HTTP's own definition of 409: the request conflicts&lt;br&gt;
with the current state of the resource, and the client is told exactly&lt;br&gt;
how to resolve it — "fetch it again before retrying". The losing client&lt;br&gt;
re-reads the record, sees the other writer's change, and a human decides&lt;br&gt;
what to keep. The silent overwrite becomes a visible decision point,&lt;br&gt;
which is the entire purpose of the exercise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the rest of the reference service proves
&lt;/h2&gt;

&lt;p&gt;The version field is one layer of a small system, and the rest of it is&lt;br&gt;
verifiable the same way — every row below is a class or a config file in&lt;br&gt;
the repository, not a claim:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Bounded Caffeine cache on single-post reads (max 500, TTL 10 min, both from config); &lt;code&gt;@Cacheable&lt;/code&gt; on read, &lt;code&gt;@CachePut&lt;/code&gt; on update, &lt;code&gt;@CacheEvict&lt;/code&gt; on delete; &lt;code&gt;GET /api/showcase/cache&lt;/code&gt; exposes requests, hits, misses and hit rate&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CacheConfig&lt;/code&gt;, &lt;code&gt;CacheProperties&lt;/code&gt;, &lt;code&gt;ShowcaseMetricsController&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;Stateless HTTP Basic with BCrypt; pages and read APIs are public, every mutation needs the &lt;code&gt;EDITOR&lt;/code&gt; role; local defaults are a throwaway &lt;code&gt;demo-editor&lt;/code&gt;/&lt;code&gt;changeit&lt;/code&gt; account overridable via env vars&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SecurityConfig&lt;/code&gt;, &lt;code&gt;application.yml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors&lt;/td&gt;
&lt;td&gt;Request records validate at the boundary; one &lt;code&gt;@RestControllerAdvice&lt;/code&gt; returns consistent problem documents with machine codes — &lt;code&gt;POST_NOT_FOUND&lt;/code&gt; (404), &lt;code&gt;POST_VERSION_CONFLICT&lt;/code&gt; (409), &lt;code&gt;VALIDATION_FAILED&lt;/code&gt; (400) with a field-error map&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CreatePostRequest&lt;/code&gt;, &lt;code&gt;UpdatePostRequest&lt;/code&gt;, &lt;code&gt;ApiExceptionHandler&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration&lt;/td&gt;
&lt;td&gt;Local profile runs an in-memory H2 database in PostgreSQL mode with &lt;code&gt;ddl-auto: update&lt;/code&gt; and seeded sample posts; &lt;code&gt;prod&lt;/code&gt; selects PostgreSQL, takes every credential from &lt;code&gt;APP_*&lt;/code&gt; env vars and sets &lt;code&gt;ddl-auto: validate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;application.yml&lt;/code&gt;, &lt;code&gt;application-prod.yml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;@SpringBootTest&lt;/code&gt; suite asserts the rendered Thymeleaf pages, unauthenticated 401s versus editor 201s, the &lt;code&gt;VALIDATION_FAILED&lt;/code&gt; problem response, public search, and cache hits after repeated reads&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DemoApplicationIntegrationTest&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Hardening This Pattern for Production Rollout
&lt;/h2&gt;

&lt;p&gt;Two things change the moment this pattern moves to production. The project&lt;br&gt;
README says both, and they are worth repeating as my own judgement.&lt;/p&gt;

&lt;p&gt;First, schema management. I would add Flyway or Liquibase migrations&lt;br&gt;
before ever setting &lt;code&gt;ddl-auto&lt;/code&gt; to &lt;code&gt;validate&lt;/code&gt;. The local development profile&lt;br&gt;
uses &lt;code&gt;update&lt;/code&gt;, which is convenient on a scratch database and dangerous as&lt;br&gt;
a habit — production schema should be versioned code, not a side effect&lt;br&gt;
of startup.&lt;/p&gt;

&lt;p&gt;Second, secrets. The local editor account is intentionally a non-secret&lt;br&gt;
demo account with default credentials. For anything real, I would keep&lt;br&gt;
credentials in a managed secret store and refuse to boot without them,&lt;br&gt;
instead of falling back to a default. The &lt;code&gt;prod&lt;/code&gt; profile already takes&lt;br&gt;
every credential from environment variables with no committed secrets —&lt;br&gt;
that is the shape I would keep, minus the fallbacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;A stale update now returns HTTP 409 with code &lt;code&gt;POST_VERSION_CONFLICT&lt;/code&gt;&lt;br&gt;
instead of a 200 that silently discards the other writer's change. The&lt;br&gt;
loss can no longer happen quietly: the failure is loud, actionable —&lt;br&gt;
"fetch it again before retrying" — and the integration test suite asserts&lt;br&gt;
the access and validation behaviour end to end.&lt;/p&gt;

&lt;p&gt;The whole fix is one annotation on one field, plus plumbing that makes&lt;br&gt;
the version part of the contract. If a record can be read by two people&lt;br&gt;
at the same time, that field is the difference between a lost edit and a&lt;br&gt;
conflict both clients can see.&lt;/p&gt;

&lt;p&gt;I build Java/Spring back ends and full-stack web applications — REST APIs&lt;br&gt;
with JPA, optimistic locking, caching, validation and integration tests,&lt;br&gt;
from the browser page down to the database. If you have a form or a field&lt;br&gt;
where edits keep quietly disappearing, tell me about it:&lt;br&gt;
&lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp&lt;/a&gt; ·&lt;br&gt;
&lt;a href="mailto:me@hoelee.com?subject=Spring%20Boot%20help"&gt;me@hoelee.com&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>spring</category>
      <category>programming</category>
      <category>sql</category>
    </item>
    <item>
      <title>When a Self-Hosted Model Gateway Tripped Trust &amp; Safety</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Fri, 06 Nov 2026 00:17:31 +0000</pubDate>
      <link>https://dev.to/hoelee/when-a-self-hosted-model-gateway-tripped-trust-safety-2odf</link>
      <guid>https://dev.to/hoelee/when-a-self-hosted-model-gateway-tripped-trust-safety-2odf</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/when-a-self-hosted-model-router-tripped-trust-and-safety/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A single self-hosted OpenAI-compatible gateway served every model tier: long-context reading, refactors, cheap background jobs. One key, one model picker, one dashboard where spend was visible in one place. Six independent workflow seats — repo indexing, CI triage, interactive refactors — sat behind it, each authenticating with its own subscription OAuth credential.&lt;/p&gt;

&lt;p&gt;Then all six came back disabled inside one short detection window. None of them had been doing anything unusual. What tripped was a scheduled credential check, and what it found was a block already applied upstream.&lt;/p&gt;

&lt;p&gt;The interesting part was not the block. It was the ten minutes I spent rotating credentials that were never wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The error that sent me the wrong way
&lt;/h2&gt;

&lt;p&gt;For the first ten minutes, every consumer of the gateway's error reported a credential failure — orchestrator, shell probe, CLI — because that is what the body said: &lt;code&gt;401 invalid_api_key&lt;/code&gt;. The upstream had actually returned a policy decision.&lt;/p&gt;

&lt;p&gt;Three probes separated the layers, and they are worth keeping for any "the model stopped answering" ticket: confirm the gateway answers its own health endpoint; confirm a model from an unrelated provider succeeds on the same key; then loop the subscription models and compare the error codes. One model failing means a broken model. Every model on one provider failing identically, while other providers succeed on the same key, means the provider relationship is closed — and no key rotation moves that.&lt;/p&gt;

&lt;p&gt;The real defect is the error contract, not the outage:&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;# Before: policy decisions disguised as auth failures
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid_api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;

&lt;span class="c1"&gt;# After: distinct classes, distinct client behaviour
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terms of Service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disabled in this account&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&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="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upstream_policy_block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;upstream_status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                   &lt;span class="n"&gt;alert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credential-policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;invalid_api_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 401 that means "your key is wrong" and a 403 that means "the vendor closed this seat" must never collapse into the same response. Flattening them costs the responder the first ten minutes of every incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a shared token is not a shared key
&lt;/h2&gt;

&lt;p&gt;An API key is a bearer credential: whoever holds it can use it. An OAuth token is bound to a client identity — client id, consent record, scopes. When a different process presents that token, the request keeps the claims and loses the behaviour: headers, TLS fingerprint, request cadence.&lt;/p&gt;

&lt;p&gt;Two properties of the initial gateway topology made the pattern obvious to any detection system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One egress.&lt;/strong&gt; Every seat left through one container on a single public address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One fingerprint.&lt;/strong&gt; The gateway normalised headers and stripped client telemetry, so six workflow seats looked like one client at six times the volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Simultaneous failures across independent workflows are not six human reviews. They are one deterministic cluster rule firing. The vendor's terms had said it in advance: scripting the official CLI is acceptable, pooling seats or automating rotation across them is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task dispatch instead of routing
&lt;/h2&gt;

&lt;p&gt;The lesson is not "use fewer seats". It is that a subscription is a seat, not an API. The moment its token leaves the vendor's client, it becomes a shared credential.&lt;/p&gt;

&lt;p&gt;So the gateway keeps serving models paid for by API key, and subscription work moved to task dispatch: the orchestrator runs isolated, single-task invocations of the official CLI bound to a dedicated workflow seat. No pooled credentials, no hijacked persistent session, no rotation. The token never leaves the vendor's binary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orchestrator -&amp;gt; task dispatcher -&amp;gt; official CLI (owns the token) -&amp;gt; vendor
                       |
                       +- cwd, permission scope, timeout, tree-kill, no retries
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four dispatcher properties mattered more than any prompt:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Explicit working directory&lt;/strong&gt; — each task starts inside one repository, never a user home directory or a filesystem root, because an agent with edit rights inherits the whole shell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closed stdin&lt;/strong&gt; — interactive prompts in a headless run become errors instead of silent hangs. One forgotten &lt;code&gt;-y&lt;/code&gt; otherwise blocks the parent forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process-group kill on timeout&lt;/strong&gt; — killing the parent leaves grandchildren running; kill the group and return a timeout code, not a half-finished diff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No automatic retries&lt;/strong&gt; — an inner agent loop times an outer retry policy is hundreds of upstream calls in minutes, the exact cadence that got the previous design flagged.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;proc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;# no interactive hangs
&lt;/span&gt;    &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PIPE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_new_session&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                         &lt;span class="c1"&gt;# own process group
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;communicate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;task_timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimeoutExpired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;killpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGKILL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;             &lt;span class="c1"&gt;# kill the whole tree
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Context stopped travelling in prompts as well: the delegate reads repository instruction files and queries a self-hosted memory service over MCP, so a brief stays six lines instead of a transcript dump. Delegate writes are tagged &lt;code&gt;status: draft&lt;/code&gt; and final archiving stays with the orchestrator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The economics survived, because the workload splits
&lt;/h2&gt;

&lt;p&gt;Nine turns in ten are routine orchestration — tool loops, retrieval, cron, log triage, short edits. Those run on a curated open-weight plan: $10/month carrying a &lt;strong&gt;$100 monthly allowance&lt;/strong&gt; on the fast model, with per-model pools beside it. At $0.15 per million input tokens and $0.60 per million output, a 4k-in/500-out turn costs about &lt;code&gt;$0.0009&lt;/code&gt; — under a tenth of a cent.&lt;/p&gt;

&lt;p&gt;The remaining tenth is heavy engineering: multi-file refactors, legacy archaeology, anything with a real build-and-test contract. That dispatches to an authorised CLI seat, where the vendor's client owns the credential and the allowance belongs to the subscription rather than to a pooled bucket.&lt;/p&gt;

&lt;p&gt;Pay for frequency with API-key models, pay for depth with authorised seats, and never let the two share a token.&lt;/p&gt;

&lt;h3&gt;
  
  
  💬 Let's Compare Notes
&lt;/h3&gt;

&lt;p&gt;If a credential is bound to a client, where should a platform team draw the execution boundary when metered APIs and client-bound CLI tools have to coexist: standardise on pure APIs and pay the premium, or run a dispatcher that governs official binaries? I have moved to the second, and I would like to hear from anyone who has run the first at team scale and made it survive an audit.&lt;/p&gt;

&lt;p&gt;Two related posts from the same infrastructure work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.hoelee.com/posts/slashing-agent-context-with-a-code-graph/" rel="noopener noreferrer"&gt;Slashing agent context with a code graph&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.hoelee.com/posts/it-reported-success-nothing-had-changed/" rel="noopener noreferrer"&gt;It reported success. Nothing had changed.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>security</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Embedding Fonts So a PDF Looks the Same on Every Machine</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Thu, 05 Nov 2026 04:25:03 +0000</pubDate>
      <link>https://dev.to/hoelee/embedding-fonts-so-a-pdf-looks-the-same-on-every-machine-2jbi</link>
      <guid>https://dev.to/hoelee/embedding-fonts-so-a-pdf-looks-the-same-on-every-machine-2jbi</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/embedding-fonts-so-a-pdf-looks-the-same-everywhere/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The requirement was one sentence
&lt;/h2&gt;

&lt;p&gt;A report generator has to produce a document that looks the same everywhere:&lt;br&gt;
Chinese headings in a serif, Chinese body text in a sans, Latin headings and&lt;br&gt;
cover numerals in a display serif, Latin body text and tables in Inter.&lt;br&gt;
Weights matter — medium for emphasis, semibold for headings — across three&lt;br&gt;
languages and two PDF renderers.&lt;/p&gt;

&lt;p&gt;And the fonts must travel with the document. Not "the server has them&lt;br&gt;
installed", not "the reader probably has them" — &lt;strong&gt;embedded&lt;/strong&gt;, so a print&lt;br&gt;
shop in another country sees the same pages.&lt;/p&gt;

&lt;p&gt;The old pipeline failed on the last clause in a way that was invisible: it&lt;br&gt;
depended on fonts installed on the server by a package manager, and it only&lt;br&gt;
ever embedded a single CJK face. The second weight did not exist, so "bold"&lt;br&gt;
was the renderer's synthetic approximation, and the only way to notice was&lt;br&gt;
to open the PDF and look at what was actually inside it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule one: read the fonts back out of the finished PDF
&lt;/h2&gt;

&lt;p&gt;"Embedded" is a claim about the output, so I measured the output. The same&lt;br&gt;
document, rendered before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;zh page count&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;en page count&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;29&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bm page count&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fonts actually embedded&lt;/td&gt;
&lt;td&gt;one CJK face + Roboto&lt;/td&gt;
&lt;td&gt;Source Han Serif Medium + SemiBold · Source Han Sans Regular + Medium · Cormorant Garamond Medium + SemiBold · Inter Regular + SemiBold + Medium · Source Han Sans for the CJK runs inside Latin reports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;render time / peak memory&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;same order (±10%, noise)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;file size&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;roughly +3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The glyph counts per face are the receipt: in the Chinese report, 15,988&lt;br&gt;
glyphs land in the body sans, 2,247 in its medium weight, 634 in the heading&lt;br&gt;
serif medium, 222 in its semibold. That is the difference between "the font&lt;br&gt;
is on the server" and "the font is in the file" — a reader in another&lt;br&gt;
country sees the same four faces, and a page count that moved by exactly one&lt;br&gt;
page in one language (the Latin metrics are wider than what they replaced)&lt;br&gt;
is the honest cost.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule two: static instances only, and the reason is in the parser
&lt;/h2&gt;

&lt;p&gt;The obvious modern choice — ship one variable font per family — is wrong&lt;br&gt;
here, for two measured reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The PDF engine's font parser reads &lt;code&gt;glyf&lt;/code&gt;/&lt;code&gt;loca&lt;/code&gt; only.&lt;/strong&gt; Feed it an
OpenType file with PostScript/CFF outlines and it refuses:
&lt;code&gt;Fonts with postscript outlines are not supported&lt;/code&gt;. A variable font is
not the problem; CFF outlines are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not read the &lt;code&gt;fvar&lt;/code&gt; table at all&lt;/strong&gt;, so a variable font silently
resolves to its &lt;em&gt;default instance&lt;/em&gt; — and the default instances of the CJK
families are the thinnest ones (Thin 100 and ExtraLight 200, visible in
the &lt;code&gt;name&lt;/code&gt; table). Every page would render in a hairline weight, with no
error anywhere in the pipeline.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the build step takes the upstream variable fonts and &lt;strong&gt;instantiates&lt;br&gt;
static cuts&lt;/strong&gt;: it runs the instancer, then rewrites &lt;code&gt;name&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;OS/2.usWeightClass&lt;/code&gt; and &lt;code&gt;head.macStyle&lt;/code&gt; so the file identifies itself as&lt;br&gt;
the weight it now is (the source's own name still says "Thin"). A manifest&lt;br&gt;
records family, weight class, sha256, glyph count and code-point count for&lt;br&gt;
every file, and the licence files ship next to them.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule three: the engine speaks two weights, so map onto two slots
&lt;/h2&gt;

&lt;p&gt;The renderer's CSS handling honours exactly &lt;code&gt;normal&lt;/code&gt; and &lt;code&gt;bold&lt;/code&gt; — its source&lt;br&gt;
says so in as many words ("Does not support: bolder, lighter, 100..900").&lt;br&gt;
Numeric weights are therefore meaningless to it, while the headless-Chromium&lt;br&gt;
renderer in the same pipeline honours them properly.&lt;/p&gt;

&lt;p&gt;The mapping that works: each family declares an &lt;strong&gt;R slot&lt;/strong&gt; and a &lt;strong&gt;B slot&lt;/strong&gt;&lt;br&gt;
(regular-ish and heavier), &lt;code&gt;font-weight: 500&lt;/code&gt; lands on the R slot of the&lt;br&gt;
intended family, and &lt;code&gt;600&lt;/code&gt; is &lt;strong&gt;rewritten to &lt;code&gt;bold&lt;/code&gt;&lt;/strong&gt; on the PDF side only,&lt;br&gt;
which lands on the B slot. Chromium keeps &lt;code&gt;600&lt;/code&gt; as written. Two tests pin&lt;br&gt;
it: the B slot must be heavier than the R slot (read from &lt;code&gt;usWeightClass&lt;/code&gt;,&lt;br&gt;
not assumed), and &lt;code&gt;font-weight: 600&lt;/code&gt; must never appear in the stylesheet&lt;br&gt;
handed to the PDF engine.&lt;/p&gt;

&lt;p&gt;One subtlety worth the paragraph: a "medium" weight is not an emphasis&lt;br&gt;
mechanism in this pipeline, it is a &lt;em&gt;family member&lt;/em&gt;. Chinese headings use&lt;br&gt;
the serif's medium as their regular and its semibold for emphasis; Latin&lt;br&gt;
headings do the same with the display serif. Getting that wrong is invisible&lt;br&gt;
on a screen and obvious in print.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rule four: do not subset against a character set you do not control
&lt;/h2&gt;

&lt;p&gt;The first attempt subset the CJK fonts to the union of two national&lt;br&gt;
character sets and produced a ~29 MB bundle instead of ~50 MB. Then I ran&lt;br&gt;
the name probe and found these were cut:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;犇  骉  頔  祎  廸  玧  祾  赟  堃
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of them is a real given name character. The report prints a&lt;br&gt;
customer's name on the cover, and the name is free input — so a subset-based&lt;br&gt;
build will eventually put a tofu box on the most prominent page of the&lt;br&gt;
product. Full coverage means keeping ~31k code points — roughly 50 MB of static font assets in the repository (adding only ~3% to each finished PDF after engine subsetting); that is a fine price for never showing a customer an empty rectangle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule five: make missing glyphs a build failure, not a surprise
&lt;/h2&gt;

&lt;p&gt;The gate runs three checks and exits non-zero on any of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every font file declared in the manifest is present, and its sha256
matches the file on disk;&lt;/li&gt;
&lt;li&gt;every character in the content library (all three languages) plus the
brand strings, plus probe classes — simplified, traditional, CJK
punctuation, full-width forms, mixed-script, digits, Latin extended,
symbols — plus the name characters, has a glyph &lt;strong&gt;in the face that will
actually print it&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;the lookup uses the PDF engine's own font parser rather than a third-party
library, so the check cannot disagree with the renderer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The known edges are written down instead of hidden. Dingbats and emoji (✅ ✔)&lt;br&gt;
exist in none of these text fonts and the engine cannot draw colour emoji,&lt;br&gt;
so they are kept out of the report body. The display serif has only 974&lt;br&gt;
glyphs and does not draw symbols like ① ★ ℃, which is why the&lt;br&gt;
content-library check covers &lt;em&gt;all&lt;/em&gt; slots rather than just the body. And CJK&lt;br&gt;
characters inside a Latin report must go through the dedicated CJK slot —&lt;br&gt;
two tests keep the renderers from disagreeing about that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Choose faces and weights before styling anything.&lt;/strong&gt; Retrofitting a
second weight after the layout exists means re-checking every page count
by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the renderer's source before choosing a font format.&lt;/strong&gt; The
variable-font decision looked like a taste question and was actually a
parser question — two files settled it in ten minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat everything you ship inside a document as a licence artifact.&lt;/strong&gt;
Font files enter the repo with their licences, or they enter someone
else's legal review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure what got embedded.&lt;/strong&gt; Page count, glyph-per-face counts, file
size, render time: all four moved, and three of them moved in ways I
would have guessed wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Before: one CJK face and a UI font, with synthetic bold, depending on what&lt;br&gt;
the server had installed. After: four families and eight static faces&lt;br&gt;
embedded in the file, weights that are real members of their families, page&lt;br&gt;
counts within one page of the previous layout, and a lint gate that fails&lt;br&gt;
the build when a character has no glyph — so the failure mode moved from "a&lt;br&gt;
customer sees a box" to "CI refuses the release". Which is the same trade as&lt;br&gt;
everywhere else in this pipeline: make the mistake impossible to ship rather&lt;br&gt;
than easy to notice.&lt;/p&gt;




&lt;p&gt;I build automated document generation and multi-lingual PDF publishing pipelines — from layout engine customisation and compliant font embedding to CI visual verification gates. If your business reports, invoices, or client deliverables need to look identical on every machine, get in touch: &lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp +60 12-797 2969&lt;/a&gt; · &lt;a href="mailto:me@hoelee.com?subject=PDF%20Typography%20Pipeline"&gt;me@hoelee.com&lt;/a&gt; · &lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>tutorial</category>
      <category>python</category>
      <category>i18n</category>
    </item>
    <item>
      <title>How I Vet an Open-Source Dependency Before Betting On It</title>
      <dc:creator>Hoelee</dc:creator>
      <pubDate>Wed, 04 Nov 2026 04:26:01 +0000</pubDate>
      <link>https://dev.to/hoelee/how-i-vet-an-open-source-dependency-before-betting-on-it-73f</link>
      <guid>https://dev.to/hoelee/how-i-vet-an-open-source-dependency-before-betting-on-it-73f</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on my blog — &lt;a href="https://blog.hoelee.com/posts/vetting-an-open-source-dependency-before-you-bet-on-it/" rel="noopener noreferrer"&gt;read it there with the full context, code and updates&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I was handed a 1,306-line architecture spec for a partner program. It read like a&lt;br&gt;
plan: consistent, cross-referenced, confident. It named a self-hosted&lt;br&gt;
open-source engine as the attribution and commission layer, and it assumed three&lt;br&gt;
things about that engine — that it spoke OIDC so my existing identity provider&lt;br&gt;
could log partners in, that its commission rules covered the five shapes the&lt;br&gt;
business needed, and that its white-label feature was shipped.&lt;/p&gt;

&lt;p&gt;Ten minutes of &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; later, two of those were false and the third was&lt;br&gt;
half-true. None of that is unusual. The README describes the &lt;em&gt;product&lt;/em&gt;; the&lt;br&gt;
repository describes the &lt;em&gt;software&lt;/em&gt;. The gap between those two is exactly where&lt;br&gt;
integration projects die — and it is cheap to measure before you write any code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem, phrased as the question I'd actually search for
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I know an open-source project does what its documentation claims before&lt;br&gt;
I build on it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A spec is a claim &lt;em&gt;about other software&lt;/em&gt;. The more polished the spec, the more its&lt;br&gt;
assumptions read like facts — this one cited sources, defined stable IDs, and had&lt;br&gt;
a source-of-truth matrix. What it never did was check whether the engine down the&lt;br&gt;
chain implemented the features being assigned to it. So before installing&lt;br&gt;
anything, I went looking for those specific features.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first, and why it wasn't enough
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Read the README end to end.&lt;/strong&gt; It sells the pipeline well:&lt;br&gt;
&lt;code&gt;click → identity → event → attribution → commission → payout&lt;/code&gt;. It never once&lt;br&gt;
says which authentication protocol the app speaks. For a feature you &lt;em&gt;need&lt;/em&gt;,&lt;br&gt;
silence isn't neutral — but an absent mention is easy to skim past when the rest&lt;br&gt;
of the page is convincing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Searched the web.&lt;/strong&gt; Several of my queries returned nothing usable. The&lt;br&gt;
project is a few months old and its SEO surface is thin. For software that's&lt;br&gt;
fine — the source &lt;em&gt;is&lt;/em&gt; the documentation. It just means the reading has to happen&lt;br&gt;
in the repo, not in a blog post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Found a 78 KB feature design doc and assumed the feature shipped.&lt;/strong&gt; It was&lt;br&gt;
labelled "FINAL, post-review", listed three reviewers, and carried a date. I took&lt;br&gt;
that as proof. That was the worst inference of the three, and the easiest to check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six checks that actually settled it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Read the repo's vital signs before any feature claim
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"User-Agent: hermes"&lt;/span&gt; https://api.github.com/repos/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'"created_at"|"pushed_at"|"stargazers_count"|"forks_count"|"open_issues_count"|"archived"|"spdx_id"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What came back: MIT, created 2026-04-23, last push 2026-09-10, 8 stars, 3 forks,&lt;br&gt;
15 open issues, not archived. Read the same as: &lt;strong&gt;real, still moving, and very&lt;br&gt;
small&lt;/strong&gt; — one vendor, which also sells a hosted tier. Now I know how much of my&lt;br&gt;
business I want resting on it &lt;em&gt;before&lt;/em&gt; I start grading its features.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Grep for the feature instead of reading for it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; &lt;span class="s1"&gt;'oidc\|sso\|saml'&lt;/span&gt; README.md ARCHITECTURE.md docs/&lt;span class="k"&gt;*&lt;/span&gt;.md | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero hits. The app authenticates with its own magic-link sessions and&lt;br&gt;
per-persona API keys; it does not speak OIDC at all. That one line of output&lt;br&gt;
killed a phase of the plan — "put the portal behind our SSO provider" — which&lt;br&gt;
would otherwise have surfaced weeks later, mid-integration, with a partner&lt;br&gt;
already invited. Reading for a feature you can't find is how you lose an&lt;br&gt;
afternoon; grepping makes the absence loud.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Read the target branch line at the top of a feature doc
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt; docs/white-label-custom-domains.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Target branch: multi-tenant&lt;/code&gt;. The doc is a design for a branch that is not the&lt;br&gt;
default branch — it describes work in flight, so the feature is not in the code&lt;br&gt;
you would install. A finished-looking document with reviewers and dates can&lt;br&gt;
document something unshipped. That isn't dishonesty; it's what design docs are.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Read the data model, not the feature list
&lt;/h3&gt;

&lt;p&gt;The commission section named exactly two rule types — &lt;code&gt;percent&lt;/code&gt; (with a recurring&lt;br&gt;
flag) and &lt;code&gt;fixed&lt;/code&gt;. The business spec had enumerated five. That gap isn't a bug;&lt;br&gt;
it's a modelling job I'd have to do in configuration instead of expecting from&lt;br&gt;
the product:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Spec assumed&lt;/th&gt;
&lt;th&gt;What the engine actually has&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;first-order bonus&lt;/td&gt;
&lt;td&gt;a &lt;code&gt;fixed&lt;/code&gt; rule on the first event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;event-specific rules (&lt;code&gt;lead&lt;/code&gt; vs &lt;code&gt;invoice_paid&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;one rule per campaign — so: two campaigns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;recurring commission&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;percent&lt;/code&gt; + &lt;code&gt;recurring: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;percentage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;percent&lt;/code&gt; ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fixed&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;fixed&lt;/code&gt; ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rule primitives, five behaviours — workable, but only if you design for it up&lt;br&gt;
front instead of discovering it during implementation.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Check which paid tier gates the feature you need
&lt;/h3&gt;

&lt;p&gt;For the database tool in the same stack, the edition table was blunt:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature I needed&lt;/th&gt;
&lt;th&gt;Community (free)&lt;/th&gt;
&lt;th&gt;Enterprise (paid)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API tokens, so I can automate it&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO / OIDC&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page designer, hide branding, 2FA&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The thing I wanted for the partner-facing door (SSO) sits behind the commercial&lt;br&gt;
gate, while the thing I wanted for automation (an API token) is free. Knowing&lt;br&gt;
which side of the wall each requirement falls on changes the design — before&lt;br&gt;
you've designed around it.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Check that the money rail works in your country
&lt;/h3&gt;

&lt;p&gt;The payout model was &lt;code&gt;stripe_connect | manual&lt;/code&gt;, and self-hosted mode was&lt;br&gt;
documented as "operator manages out-of-band" — i.e. you move the money yourself.&lt;br&gt;
Stripe Connect is not how Malaysian businesses pay partners, so that one line&lt;br&gt;
decided the entire deployment shape: self-host mode, manual payouts, and event&lt;br&gt;
ingestion pushed from my own order system rather than leaning on the provider's&lt;br&gt;
payment integration. That line is never on a marketing page, and it mattered more&lt;br&gt;
than any feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  Then: ask the fallback question before you need it
&lt;/h3&gt;

&lt;p&gt;"What if the feature I need isn't there?" For the missing SSO the answer was:&lt;br&gt;
don't put login in the app, put it in front of the app. authentik's proxy&lt;br&gt;
provider exists to protect "applications that do not support native&lt;br&gt;
authentication protocols such as OIDC, SAML, or LDAP", and it injects&lt;br&gt;
&lt;code&gt;X-authentik-username&lt;/code&gt; and &lt;code&gt;X-authentik-groups&lt;/code&gt; headers upstream, so the reverse&lt;br&gt;
proxy can do the gating. That turned "no SSO support" from a disqualification&lt;br&gt;
into an afternoon of config.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write the requirement list first, then go look for it.&lt;/strong&gt; Five lines — the
features &lt;em&gt;I&lt;/em&gt; need — and search for those. Starting from the README's feature
list means grading the software on the vendor's exam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "a doc exists" as different from "a feature ships".&lt;/strong&gt; Read the branch
line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep, don't read.&lt;/strong&gt; Presence is cheap to prove; absence is the thing you're
hunting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide the fallback before the decision&lt;/strong&gt;, so a missing feature becomes a
design change rather than a surprise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the commit.&lt;/strong&gt; This project's README says its API is "stable but
unversioned" and there is no OpenAPI description anywhere in the tree. There's
no contract to compile against, so the commit hash &lt;em&gt;is&lt;/em&gt; the contract — the
endpoints I use go into my own docs, and upgrades get a regression pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;Six checks and about ten minutes of &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; corrected all three faulty assumptions in the spec before a single line of integration code existed. The project stayed viable — but&lt;br&gt;
reshaped: no OIDC, manual payouts, a pinned commit, and a plan that no longer&lt;br&gt;
depends on a feature sitting on an unmerged branch.&lt;/p&gt;

&lt;p&gt;The spec wasn't wrong to be optimistic. It was wrong to be unverified, and that's&lt;br&gt;
the cheapest thing on the whole project to fix.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Building on a self-hosted stack, and want a second pair of eyes on whether&lt;br&gt;
those features will actually be there when you integrate?&lt;/strong&gt; That's the kind of&lt;br&gt;
review I do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wa.me/60127972969" rel="noopener noreferrer"&gt;WhatsApp +60 12-797 2969&lt;/a&gt; ·&lt;br&gt;
&lt;a href="mailto:me@hoelee.com?subject=Open-source%20dependency%20review"&gt;me@hoelee.com&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://hoelee.com" rel="noopener noreferrer"&gt;hoelee.com&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I write about self-hosted infrastructure, DevOps and the failures behind them at &lt;a href="https://blog.hoelee.com/" rel="noopener noreferrer"&gt;blog.hoelee.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>devops</category>
      <category>programming</category>
      <category>security</category>
    </item>
  </channel>
</rss>
