<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://philipptheserver.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://philipptheserver.com/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-09-06T15:41:36+02:00</updated><id>https://philipptheserver.com/feed.xml</id><title type="html">Philipp Lehmann</title><subtitle>Infrastructure engineer in Bochum, Germany. CTO at Nerd Force1 UG (AI-Gruppe) and IT-Security student at Ruhr-Universität Bochum. I write about container platforms, distributed storage, and automating everything that shouldn&apos;t be done twice.</subtitle><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><entry><title type="html">Running the models yourself</title><link href="https://philipptheserver.com/posts/atlas-agentic-ops/" rel="alternate" type="text/html" title="Running the models yourself" /><published>2026-09-06T13:00:00+02:00</published><updated>2026-09-06T13:00:00+02:00</updated><id>https://philipptheserver.com/posts/atlas-agentic-ops</id><content type="html" xml:base="https://philipptheserver.com/posts/atlas-agentic-ops/"><![CDATA[<p>Every prompt you send to a hosted model is a copy of your data leaving your building. For
a lot of work that is completely fine. For infrastructure work — where the context you
would naturally paste is config, logs, topology, sometimes credentials — it deserves at
least a decision rather than a default.</p>

<p>That is the reason atlas exists: a self-hosted inference stack on a machine I own, an
OpenAI-compatible API in front of it, benchmarks to tell me whether the models are actually
any good, and a daemon that hands them real work. Every agent session I run — editor,
terminal, CI — goes to that endpoint rather than out of the building.</p>

<h2 id="why-own-the-hardware">Why own the hardware</h2>

<p><strong>Data locality.</strong> The strongest argument. Debugging context is exactly the material you
would least like to send somewhere else. Local inference makes that a non-question rather
than a policy you have to remember.</p>

<p><strong>Predictable cost.</strong> Hosted inference is priced per token, which means your bill is a
function of how useful you find it. That is a strange incentive — it makes people ration a
tool that gets better the more you use it. A GPU is a fixed cost and marginal use is free.</p>

<p><strong>Latency and availability.</strong> No round trip, no rate limits, no dependency on somebody
else’s capacity planning.</p>

<p><strong>It still works when the internet does not.</strong> Which is precisely when you are debugging.</p>

<p>The honest counterweight: the best hosted models are better than what you can run
locally, and the gap on hard reasoning is real. Local models are excellent at the bulk of
practical work — summarising, transforming, drafting, structured extraction, code you will
review anyway — and noticeably weaker at the genuinely difficult problems. Pretending
otherwise leads to disappointment. Use both, and know which is which.</p>

<h2 id="serving-them">Serving them</h2>

<p>The practical shape is a model server behind an API. Three things matter more than the
choice of server:</p>

<p><strong>Model swapping.</strong> You cannot hold every model in VRAM at once, and you do not want a
separate service per model. A layer that loads on demand and evicts what is idle turns a
fixed allocation into a pool. It costs a cold-start delay on the first request, which is
almost always the right trade.</p>

<p><strong>An OpenAI-compatible API.</strong> Not because that API is well designed, but because
everything already speaks it. Adopting it means every tool, library and editor plugin
works against your own hardware with a changed base URL and nothing else. Compatibility
with a widespread interface beats a better interface nobody implements.</p>

<p><strong>Quantisation is the lever.</strong> The difference between a model that does not fit and one
that fits comfortably is usually quantisation, and the quality cost at sensible levels is
much smaller than people expect. This is what decides whether a given GPU is useful.</p>

<h2 id="it-feels-smarter-is-not-a-measurement">“It feels smarter” is not a measurement</h2>

<p>This is the part I would push hardest.</p>

<p>Model evaluation by vibes is the norm and it is worthless. You try a new model, ask it a
few things, form an impression, and swap. Your impression is shaped by the last thing you
happened to ask, by prompt phrasing, and by wanting the new thing to be better.</p>

<p>So atlas has benchmarks: a fixed set of tasks representative of what I actually use models
for, run against every served model, repeatably. Not academic benchmarks — those measure
something real but not necessarily the thing you need. Your own tasks, scored consistently.</p>

<p>What this buys you is the ability to answer <em>did that upgrade help?</em> with evidence. It
also produces genuinely surprising results. Bigger is not reliably better for a specific
task. A quantisation step you assumed was lossy may cost nothing measurable on your
workload. The model everyone recommends may be worse at your actual job than a smaller one.</p>

<p>You cannot learn any of that from impressions.</p>

<h2 id="agents-touching-infrastructure">Agents touching infrastructure</h2>

<p>The interesting and dangerous part. An agent that can read logs, query metrics and propose
changes is enormously useful. An agent that can <em>apply</em> changes is a different risk
category, and the difference is worth being deliberate about.</p>

<p>The rules I hold to:</p>

<p><strong>Read-only by default, and generously so.</strong> Reading logs, metrics, configuration and
state covers most of the value. Most of what makes infrastructure work slow is gathering
context, not typing the fix. An agent that only reads is already worth having.</p>

<p><strong>Dry run before apply, always.</strong> Every serious infrastructure tool can show you what it
would do. Agents should be required to use it, and the output is the thing a human reads.
This is not agent-specific — it is the same discipline that should apply to a person — but
agents make it non-negotiable, because an agent’s confidence is uncorrelated with its
correctness.</p>

<p><strong>A human approves anything that changes state.</strong> Not a rubber stamp: the dry-run output,
read, then approved. The agent’s job is to do the work and present the evidence. The
decision stays with a person.</p>

<p><strong>Never on a base branch, never without a trail.</strong> Agent work goes through the same
process as human work — a branch, a pull request, a review, a merge. Partly for safety,
mostly so that in six months you can find out why something is the way it is.</p>

<p><strong>Evidence, not assertion.</strong> The failure mode that matters is not an agent doing something
destructive — that is what approval gates are for. It is an agent <em>reporting</em> that
something is fixed, deployed or passing without having verified it. A claim without the
command output behind it is worth nothing, and it is worse than nothing if it is believed.
I would rather be told “I could not verify this” than be told a plausible falsehood.</p>

<h2 id="what-it-looks-like-in-practice">What it looks like in practice</h2>

<p>The workflow that has actually stuck is narrower than “an agent that helps”, and the
narrowness is the point: <strong>work is handed over as a GitHub issue, and comes back as a pull
request.</strong></p>

<p>A daemon watches for issues carrying a particular label. When one appears it drives a
locally served model to do the work, and opens a pull request. I review the pull request
the way I would review anyone’s. If the issue was badly written, the result is bad, and
that is informative rather than dangerous — nothing has been applied to anything.</p>

<p>Two properties make this work, and neither is about the model.</p>

<p><strong>The daemon is deterministic.</strong> Which issue gets picked up, in what order, and what
happens to it is ordinary code with ordinary tests. The model is called at one point
inside a process whose shape does not depend on it. That is what makes the system
debuggable: when something goes wrong, it is nearly always the issue or the harness, and
both are things you can read.</p>

<p><strong>The label is a contract.</strong> Applying it is an explicit act by a person, so nothing is
ever picked up because an agent decided it was in scope. Removing it takes the work back.
It is a very small mechanism and it is the whole boundary.</p>

<p>What this buys is not speed of typing. It is that a piece of work can be described once,
handed over, and collected later as a diff — with the review step exactly where it would
be for a human contributor, because it <em>is</em> the review step for a human contributor.</p>

<p>That is a much less exciting story than autonomous operations, and it is the one that
survives contact with production. The value is real and it is in the boring half.</p>

<h2 id="where-this-is-going">Where this is going</h2>

<p>The direction I find genuinely promising is not agents that act more autonomously. It is
agents with better context — able to read the repository, the monitoring, the runbooks and
the incident history together, and tell you what changed and what it correlates with.</p>

<p>Most infrastructure problems are not hard to fix once understood. They are hard to
understand because the relevant information is spread across six systems and one person’s
memory. That is a retrieval and correlation problem, and it is one that models are
genuinely good at right now, on hardware you can own, without anything leaving the
building.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="LLM" /><category term="Self-hosting" /><category term="Agents" /><category term="Python" /><summary type="html"><![CDATA[Sending every prompt to someone else's GPU is a decision, not a default. What it takes to serve models on hardware you own, how to know whether they are actually good, and what has to be true before an agent touches production.]]></summary></entry><entry><title type="html">One login instead of twelve</title><link href="https://philipptheserver.com/posts/keycloak/" rel="alternate" type="text/html" title="One login instead of twelve" /><published>2026-09-06T12:00:00+02:00</published><updated>2026-09-06T12:00:00+02:00</updated><id>https://philipptheserver.com/posts/keycloak</id><content type="html" xml:base="https://philipptheserver.com/posts/keycloak/"><![CDATA[<p>Count the places a person’s account exists at your organisation. The wiki. The monitoring
dashboard. The git server. The registry. The ticket system. The VPN. Each one with its own
password, its own idea of who is an admin, and its own list that nobody has audited.</p>

<p>Now imagine someone leaves. What is the procedure? If the honest answer involves a
checklist and a search, the accounts you forget will outlive the employment. That is not a
hypothetical risk, it is the normal outcome, and it is the actual argument for running an
identity provider. Not convenience. <strong>Offboarding.</strong></p>

<h2 id="what-it-changes">What it changes</h2>

<p>With a central identity provider, services stop having users. They have <em>sessions</em>, handed
to them by something else that did the authenticating.</p>

<p>A person exists in one place. Disable them there and every service goes dark at once, with
no list to work through. Group membership becomes the thing that grants access, so
“who can reach production” is a question with an answer rather than an investigation.</p>

<p>Second-factor enforcement becomes a single policy instead of twelve separate features of
varying quality — half of which do not have it at all.</p>

<p>And people stop reusing one password across a dozen internal tools, because there is one
password.</p>

<h2 id="the-protocol-in-one-paragraph">The protocol, in one paragraph</h2>

<p>OIDC is the layer worth learning. A user hits a service, the service redirects them to the
identity provider, they authenticate there, and come back with a signed token asserting
who they are. The service validates the signature and trusts the claim. It never sees the
password. It does not store credentials, cannot leak them, and has no opinion about
password policy — that all lives in one place, maintained by people who thought about it.</p>

<p>The important consequence: <strong>your services stop being credential stores.</strong> Most internal
tools have unremarkable auth code written once and never revisited. Deleting all of it and
replacing it with “validate this token” is a large reduction in the amount of security
code you are responsible for.</p>

<h2 id="modelling-keep-it-boring">Modelling: keep it boring</h2>

<p>The failure mode with an identity provider is over-modelling. It will happily support
fine-grained roles, nested groups, attribute mapping and per-client scopes, and you can
build something so expressive that nobody can answer “what can this person do”.</p>

<p>Start with groups that mirror how you actually talk about access. If the sentence people
say is “the infrastructure team can get to the cluster”, make a group called that. Grant
things to groups, never to individuals — an individual grant is invisible in every review
and outlives the reason it was made.</p>

<p>Resist per-service roles until a service actually needs to distinguish two kinds of user.
Most do not. Most need “may this person in, yes or no”, and modelling three tiers of
permission for a dashboard that has one page is work you will maintain forever for no
benefit.</p>

<h2 id="services-that-cannot-speak-oidc">Services that cannot speak OIDC</h2>

<p>Some things cannot do modern auth. Old admin panels, appliances, dashboards with a single
shared password. You will have several and they are usually the ones you most want behind
authentication.</p>

<p>The pattern that solves this is a forward-auth proxy: the reverse proxy in front of the
service asks an authentication service whether this request carries a valid session, and
only passes it through if the answer is yes. The application behind it is untouched — it
does not know authentication happened, and does not need to.</p>

<p>This is unreasonably useful. It puts real, group-controlled, second-factor-enforced
authentication in front of software that has no concept of any of it, without modifying
the software. A lot of “we cannot secure that, it is legacy” turns out to be false.</p>

<p>The caveat: the proxy is now load-bearing. A route that bypasses it — a second ingress, a
port exposed directly, a health check path excluded a bit too generously — is a route with
no authentication at all. Bypass paths deserve the same scrutiny as firewall rules.</p>

<h2 id="what-it-costs">What it costs</h2>

<p><strong>It becomes a dependency of everything.</strong> When identity is down, nothing is reachable —
not because those services are broken, but because nobody can prove who they are. That is
a real concentration of risk and you should be honest about it rather than discovering it
during an incident.</p>

<p>Which means: it needs to be genuinely reliable, it needs monitoring that checks the login
flow end to end rather than just whether the process is running, and you need a documented
way in when it is down. Break-glass access that does not depend on the thing that is
broken. Written down, stored somewhere reachable without a login, and tested — an
emergency procedure nobody has ever run is a procedure that does not work.</p>

<p><strong>Sessions and tokens have subtle behaviour.</strong> Token lifetime, refresh, and what actually
happens when you disable an account mid-session are worth understanding properly. Disabling
a user does not always terminate their existing sessions immediately, and if your mental
model is “I clicked disable, they are out”, you may be wrong for the length of a token
lifetime. Find out which, before you need to rely on it.</p>

<p><strong>Upgrades are real work.</strong> It sits in front of everything, so testing an upgrade means
testing every login path. This is the price of centralising, and it is worth paying, but
it should be planned rather than done casually on a Friday.</p>

<h2 id="would-i-do-it-again">Would I do it again</h2>

<p>Immediately. Not for single sign-on, which is a pleasant side effect, but for the
property that a person is one object and access is one decision.</p>

<p>The moment that justified it was the first time someone left and offboarding was: open one
page, disable one account, done. No checklist. No wondering about the monitoring
dashboard. Everything went dark at once, because everything had been asking the same
question all along.</p>

<p>Everything else — the second factor, the audit log, the deleted auth code — is upside.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="Keycloak" /><category term="OIDC" /><category term="Security" /><summary type="html"><![CDATA[Every internal service wants its own user database. Running an identity provider means none of them get one — and offboarding becomes a single action instead of an archaeology project.]]></summary></entry><entry><title type="html">An overlay mesh, and the DNS trap underneath it</title><link href="https://philipptheserver.com/posts/netbird-vpn/" rel="alternate" type="text/html" title="An overlay mesh, and the DNS trap underneath it" /><published>2026-09-06T11:00:00+02:00</published><updated>2026-09-06T11:00:00+02:00</updated><id>https://philipptheserver.com/posts/netbird-vpn</id><content type="html" xml:base="https://philipptheserver.com/posts/netbird-vpn/"><![CDATA[<p>The traditional VPN is a hub. Everything dials in to one concentrator, all traffic goes
through it, and that box is simultaneously your security boundary, your bandwidth
bottleneck, and your single point of failure. It works. It has worked for thirty years.
It also means two machines sitting in the same room send their traffic through a data
centre in another country to talk to each other.</p>

<p>An overlay mesh inverts that. Every machine gets a stable address on a private overlay
network, and any two of them establish a direct encrypted tunnel — peer to peer, not
through a hub. A control plane handles identity, key distribution and NAT traversal, then
gets out of the path. WireGuard does the actual encryption.</p>

<p>The result is that “which network is this machine on” stops being a question you care
about. A laptop on hotel wifi, a server in a rack, a device behind a carrier NAT — all of
them have an address, and the address does not change when the network does.</p>

<h2 id="why-this-is-a-bigger-deal-than-it-sounds">Why this is a bigger deal than it sounds</h2>

<p>Once every machine has a stable identity independent of its location, a whole category of
configuration disappears.</p>

<p>You stop writing firewall rules against office IP ranges that change when the ISP feels
like it. You stop maintaining a list of which subnet is which site. Automation stops
needing different connection paths depending on where it runs. Services that should only
be reachable internally can bind to the overlay address and simply be invisible from the
internet — not firewalled off, <em>not present</em>.</p>

<p>And crucially, NAT traversal is handled for you. Getting a direct tunnel between two hosts
that are both behind NAT is genuinely difficult, and the machinery to do it — STUN, hole
punching, and a relay for the cases where it fails — is the actual value the control plane
provides. When it works, traffic is direct. When it cannot, it falls back to a relay, and
you get connectivity instead of a support ticket.</p>

<h2 id="now-the-trap">Now the trap</h2>

<p>Every mesh gives you an internal naming scheme, so you can reach peers by name rather than
by overlay address. The mesh resolver answers those names with the peer’s overlay address,
and only for machines on the mesh. That is the design and it is good.</p>

<p>Here is what nobody puts in the quick-start guide.</p>

<p>Those names are usually subdomains of a domain you own publicly. And if that public zone
has a catch-all wildcard — which an enormous number of zones do — then <strong>public DNS will
answer for your internal names too.</strong> A DNS wildcard matches at any depth, not just one
label. So a name intended to exist only inside the mesh gets a public answer, pointing at
whatever the wildcard points at, which is typically your main public host.</p>

<p>While the mesh resolver is working, nobody notices. The mesh answers first, you get the
right address, everything is fine. The problem appears the moment the mesh resolver <em>is
not</em> answering: the client restarted, the tunnel dropped, someone disabled the resolver,
the daemon is mid-reconnect. Then the query falls through to public DNS, and public DNS
answers — confidently, successfully, with the address of a completely different machine.</p>

<p>No error. Nothing fails. The name resolves. It just points somewhere else now.</p>

<p>Sit with what that means for automation. Configuration management that connects to hosts
by internal name, with credentials, running privileged operations. If a name silently
resolves to a different machine, the automation connects to that machine and does what it
was told to do. It may hand over a password on the way.</p>

<h2 id="failing-closed">Failing closed</h2>

<p>The fix is small and you should do it before you need it: publish an explicit record for
the internal namespace in your public zone that points somewhere guaranteed to be
unroutable.</p>

<p>A more specific wildcard beats the general catch-all for that subtree, so a single record
covering the internal namespace takes it out of the catch-all’s reach without touching the
records everything else depends on. Point it at documentation-reserved address space —
addresses set aside by RFC precisely so they can never route anywhere.</p>

<p>The effect is that a fall-through now <em>times out</em> instead of reaching a live machine. That
is a much better failure: slow and obviously broken, rather than fast and quietly wrong.
Fail closed, not sideways.</p>

<p>Then add the second layer, because DNS alone is not enough. Host key checking is what
turns “this is a different machine” from an invisible event into a loud one. If your
automation is configured to accept any host key without complaint — and a surprising
amount is, because it makes bootstrapping new machines convenient — then it will connect
to the wrong host without a word.</p>

<p>The configuration you want accepts a key it has never seen (so provisioning a fresh
machine still works unattended) but refuses loudly when a key <em>changes</em>. Those are
different situations and they deserve different responses. A tool that treats them the
same is one DNS blip away from an incident.</p>

<h2 id="other-things-worth-knowing">Other things worth knowing</h2>

<p><strong>The control plane is infrastructure.</strong> It handles enrolment, key exchange and policy. If
it is down, existing tunnels generally keep working, but nothing new can join and policy
changes do not propagate. Treat it accordingly — it is not a nice-to-have service, it is a
dependency of your ability to reach everything else.</p>

<p><strong>Access policy is not the same as network reachability.</strong> The mesh gives every peer an
address, and by default that can mean far more connectivity than you intended. A laptop
should probably not be able to reach every storage node. Policy is where you say so, and
it deserves the same review any firewall rule would get.</p>

<p><strong>Overlay addressing overlaps with things.</strong> Meshes commonly use carrier-grade NAT space,
which is not routable on the public internet but is very much in use inside some ISP
networks. Know which range you are on before you debug a connectivity problem that only
affects one person’s home connection.</p>

<p><strong>Understand relayed versus direct.</strong> When hole punching fails, traffic goes through a
relay, and your throughput and latency change character. It will still work, which is why
you might not notice for months — until someone copies a large file and asks why it is
slow.</p>

<h2 id="worth-it">Worth it?</h2>

<p>Yes, and not marginally. Being able to address every machine you own by a stable name,
encrypted end to end, regardless of where it physically sits, removes an entire class of
network configuration from your life.</p>

<p>Just publish the blackhole record first. The trap costs one line of DNS to close and it
is not the sort of thing you want to discover by finding out which machine your automation
has been talking to.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="NetBird" /><category term="WireGuard" /><category term="DNS" /><category term="Networking" /><summary type="html"><![CDATA[An overlay mesh gives every machine a stable address and an encrypted path to every other one. It also gives your internal names a way to silently resolve somewhere else.]]></summary></entry><entry><title type="html">Alert on symptoms, not on metrics</title><link href="https://philipptheserver.com/posts/observatory-monitoring/" rel="alternate" type="text/html" title="Alert on symptoms, not on metrics" /><published>2026-09-06T10:00:00+02:00</published><updated>2026-09-06T10:00:00+02:00</updated><id>https://philipptheserver.com/posts/observatory-monitoring</id><content type="html" xml:base="https://philipptheserver.com/posts/observatory-monitoring/"><![CDATA[<p>The first monitoring system I built alerted on CPU. It worked exactly as designed and was
almost entirely useless.</p>

<p>High CPU is not a problem. It is sometimes a <em>sign</em> of a problem, and it is sometimes a
machine doing its job well. Alerting on it taught everyone the single worst lesson a
monitoring system can teach: that alerts are things you glance at and dismiss. Once people
learn that, the system is dead, and the outage it eventually catches correctly will be
dismissed along with everything else.</p>

<h2 id="the-rule-that-fixed-it">The rule that fixed it</h2>

<p><strong>Alert on what the user experiences. Graph everything else.</strong></p>

<p>If a page is slow, alert. If a queue is not draining, alert. If a certificate expires in
three days, alert. If CPU is at 90%, put it on a dashboard and leave me alone — I will
look at it when I am investigating the alert that actually fired.</p>

<p>This distinction is not about which metrics you collect. Collect everything; it is cheap
and you will want it during an incident. It is about which ones are allowed to wake
someone. The test I use: <em>if this fires and I do nothing, will anybody notice?</em> If the
honest answer is no, it is a graph, not an alert.</p>

<p>The corollary is uncomfortable: an alert that has fired ten times and been ignored ten
times is not a monitoring gap, it is a monitoring <em>bug</em>, and the fix is to delete it or
change it. Leaving it in place is choosing to train your team to ignore alerts.</p>

<h2 id="black-box-and-white-box-are-answering-different-questions">Black-box and white-box are answering different questions</h2>

<p>White-box monitoring reads a system’s own account of itself: metrics the service exports,
queue depths, internal error counters. It tells you <em>why</em>.</p>

<p>Black-box monitoring is an outside observer doing what a user does: fetch the URL, resolve
the name, complete the handshake. It tells you <em>whether</em>.</p>

<p>You need both, and the mistake is thinking the first can replace the second. A service can
report itself perfectly healthy while being completely unreachable, because everything it
knows how to measure is inside the boundary that broke. Every layer between the process
and the user — DNS, the load balancer, certificates, firewall rules, the ingress — is
invisible to it.</p>

<p>Black-box checks are also the ones that catch the failures where nothing is broken <em>at
all</em>, which brings me to the class of outage I find most interesting.</p>

<h2 id="the-failure-where-every-metric-is-green">The failure where every metric is green</h2>

<p>Here is a shape worth internalising, because it generalises far beyond the specific
mechanism.</p>

<p>DNS wildcards do not work the way most people assume. A wildcard record synthesises an
answer for a name only if nothing exists beneath that name. Put any record — any type at
all — under a name, and the name now <em>exists</em>, and the wildcard is forbidden from
answering for it. The name then resolves with a perfectly successful response code and an
empty answer. No error. No failure. Just no address.</p>

<p>The practical consequence: a leftover record from a process that was supposed to clean up
after itself can take a hostname off the internet, silently, while every neighbouring name
served by the same wildcard keeps working perfectly. Query a random name under that
wildcard and it answers. Query the real one and you get success-with-nothing.</p>

<p>Now consider what your monitoring says. The service is up. Its metrics are green. Its
host is healthy. Certificates are valid. Every dashboard is fine, because the failure is
not in anything any of those things measure — it is in the resolution step that happens
before anyone reaches the service at all.</p>

<p>The only thing that catches this is an outside observer asking a public resolver for the
name you actually publish and checking that the answer contains an address. Not that the
query succeeded — that it <em>returned something</em>.</p>

<p>I like this example because it is not exotic. It is a category: <strong>failures where every
component reports success and the composition still does not work.</strong> Certificate chains
that validate individually and not together. A load balancer routing happily to a backend
pool that is empty. Auth that returns 200 with a redirect loop. If your monitoring only
ever asks components how they feel, none of these are visible.</p>

<h2 id="what-a-good-alert-contains">What a good alert contains</h2>

<p>An alert is a message to a tired person. Write it for them.</p>

<p>It should say what is broken in user terms, since that is what determines urgency. It
should say how you know, so the first move is not re-deriving the query. It should point
at the thing to look at. And it should be honest about severity — if it is not worth
waking someone, it should not be able to.</p>

<p>The failure mode here is alerts written as an expression and a name and nothing else. Six
months later, at 3am, <code class="language-plaintext highlighter-rouge">ProbeFailureRateHigh</code> on a service you did not deploy tells you
nothing about whether to care.</p>

<h2 id="cardinality-briefly">Cardinality, briefly</h2>

<p>The other way monitoring dies is by becoming too expensive to run, and the cause is almost
always labels. Every distinct combination of label values is a separate time series. Put a
user ID, a request path, or anything else unbounded in a label and you have built a system
whose cost grows with your traffic in a way you did not intend.</p>

<p>The rule: labels are for things you would <em>group by</em>. If you would never write a query
grouping on it, it is not a label, it is a log field.</p>

<h2 id="the-uncomfortable-part">The uncomfortable part</h2>

<p>Good monitoring is mostly deletion. The instinct when something breaks is to add an alert
for it, and after two years you have three hundred alerts, most of which have never fired
usefully, and a team that has learned the notification channel is noise.</p>

<p>Every alert should have to justify its continued existence. When was it last right? What
did someone do about it? If it has never been right, it is not protecting you — it is
using up the attention you will need for the alert that matters.</p>

<p>I would rather have twelve alerts that everyone trusts than three hundred that everyone
ignores. The twelve will catch fewer things. They will catch them at 3am, when someone
actually reads them.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="Monitoring" /><category term="Alerting" /><category term="Observability" /><summary type="html"><![CDATA[Most monitoring failures are not missing data. They are the wrong alert on good data — and the one class of outage nobody catches is the one where every metric is green.]]></summary></entry><entry><title type="html">Ceph without a vendor’s price list</title><link href="https://philipptheserver.com/posts/ceph/" rel="alternate" type="text/html" title="Ceph without a vendor’s price list" /><published>2026-09-06T09:00:00+02:00</published><updated>2026-09-06T09:00:00+02:00</updated><id>https://philipptheserver.com/posts/ceph</id><content type="html" xml:base="https://philipptheserver.com/posts/ceph/"><![CDATA[<p>The pitch for a SAN is that storage becomes somebody else’s problem. You buy a box, it has
redundant everything, and there is a phone number. The pitch is honest. The price is also
honest, and for a lot of organisations it is the single largest line in the infrastructure
budget.</p>

<p>Ceph is the other trade: ordinary machines, ordinary disks, replication in software, and
storage becomes <em>your</em> problem in exchange for costing a fraction as much. Whether that is
a good deal depends entirely on whether you are willing to learn it properly. Half-learned
Ceph is more dangerous than the SAN you were trying to avoid.</p>

<h2 id="the-model-briefly">The model, briefly</h2>

<p>Ceph stores objects across a cluster and computes <em>where</em> an object lives rather than
looking it up. That sounds like a detail and it is the central idea: there is no metadata
server to become a bottleneck or a single point of failure, because placement is a
function, not a table.</p>

<p>The pieces you actually care about day to day:</p>

<ul>
  <li><strong>OSDs</strong> — one per disk, roughly. These hold the data. Losing one is normal.</li>
  <li><strong>Monitors (MONs)</strong> — hold the cluster map and vote on it. These need quorum.</li>
  <li><strong>The CRUSH map</strong> — the description of your failure domains: which disks are in which
host, which host in which rack. This is where you tell Ceph what “independent” means.</li>
</ul>

<p>Everything above that — block devices, a filesystem, an S3-compatible gateway — sits on
top of the same object store.</p>

<h2 id="failure-domains-are-the-whole-design">Failure domains are the whole design</h2>

<p>If you take one thing away: <strong>Ceph will faithfully protect you against exactly the failure
you described, and no other.</strong></p>

<p>Three replicas sounds safe. Three replicas that happen to live on three disks in the same
machine protect you against disk failure and not against that machine’s power supply. The
default CRUSH rules usually do something sensible at the host level, but “usually” is
doing a lot of work in that sentence, and the moment your hosts are not uniform it stops
being true.</p>

<p>So the first real design decision is not how many replicas — it is <em>replicas across what</em>.
Across disks, hosts, racks, rooms? Write it down, configure it explicitly, and then verify
it by asking the cluster where a given object actually lives rather than assuming.</p>

<p>The related trap: a cluster that cannot satisfy its own rule will simply refuse to place
data, and tell you it is degraded, and wait. That is correct behaviour and it looks exactly
like a broken cluster if you were not expecting it.</p>

<h2 id="four-things-that-will-bite-you">Four things that will bite you</h2>

<p><strong>1. A full OSD stops the cluster, not just the disk.</strong></p>

<p>Ceph is protective about running out of space, because a full cluster cannot rebalance
itself out of trouble. Hit the threshold and writes stop — cluster-wide, not on that disk.
Everything sharing that pool becomes read-only, which in practice means everything.</p>

<p>Worse, utilisation is not even. Placement is pseudo-random, so some OSDs run hotter than
average, and the <em>fullest</em> disk is what matters, not the mean. A cluster that looks 70%
full can have a disk at 90%.</p>

<p>Plan capacity against the fullest OSD, alert well before the threshold, and never let a
cluster get comfortable above roughly two-thirds. You need the headroom to survive losing
a node — because when a node goes, its data re-replicates onto the remaining disks, and if
there is no room for it, one failure becomes an outage.</p>

<p><strong>2. Recovery competes with your workload.</strong></p>

<p>When a disk dies, Ceph starts making new copies. That is the system working. It is also a
large amount of I/O arriving at exactly the moment you have less hardware than usual.</p>

<p>Left at aggressive settings, recovery can make a degraded-but-working cluster feel like a
down cluster. Left too gentle, you stay at reduced redundancy for a long time, and the
second failure is the one that costs you data. There is no universally right answer, but
there is a wrong one: not having decided before it happens.</p>

<p><strong>3. MON quorum is a different failure than OSD failure.</strong></p>

<p>Lose disks and you lose redundancy. Lose monitor quorum and you lose the cluster —
everything blocks, regardless of how healthy the data is, because nobody can agree on the
map.</p>

<p>Run an odd number, spread them across genuine failure domains, and understand that two
monitors is <em>worse</em> than one: one gives you no redundancy, two gives you no redundancy and
a way to lose quorum by rebooting either machine.</p>

<p><strong>4. The network is a storage component.</strong></p>

<p>Every write is replicated, so a write is several network transfers. Latency between nodes
is latency in your storage. A cluster that behaves fine at low load can fall apart when
recovery traffic and client traffic contend on the same link.</p>

<p>This is the one people skip because network gear is boring and expensive, and it is the
one that produces the most baffling symptoms — storage that is slow in a way that
correlates with nothing on the storage nodes.</p>

<h2 id="what-it-actually-gives-you">What it actually gives you</h2>

<p>Setting the warnings aside, because they are warnings about a system worth running:</p>

<p>You get storage that grows by adding a machine instead of by a purchase order. You get to
lose a disk on a Tuesday and not care. You get to lose a <em>node</em> and still not care, if you
built the failure domains right. You get one pool of storage that presents as block
devices, as a filesystem, and as an object store, instead of three separate products.</p>

<p>And you get the thing that is hardest to price: you understand your storage. When something
is slow, you can find out why, because there is no opaque appliance in the way. That is
worth a great deal at three in the morning, and it is precisely what you gave up when you
bought the box with the phone number.</p>

<h2 id="if-you-are-starting">If you are starting</h2>

<p>Start with more nodes and fewer disks each, rather than the reverse. Node count is what
gives you real failure domains, and a cluster of three fat machines has fewer independent
things to lose than one of six thin ones.</p>

<p>Do not put the first production workload on it. Put the second. Run something real but
survivable, lose a disk on purpose, watch recovery, fill it up in a test pool until writes
stop so you have seen what that looks like. Ceph rewards operators who have already met
its failure modes and punishes the ones meeting them for the first time under load.</p>

<p>Then read the health output every day until it is boring. <code class="language-plaintext highlighter-rouge">HEALTH_WARN</code> is not a state to
live in. It is the cluster telling you what will hurt next.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="Ceph" /><category term="Storage" /><category term="Linux" /><summary type="html"><![CDATA[Ceph gives you replicated storage across ordinary machines instead of one expensive box with a support contract. It also gives you a set of failure modes that only appear when you are already unhappy.]]></summary></entry><entry><title type="html">Kubernetes, and when it earns its place</title><link href="https://philipptheserver.com/posts/kubernetes/" rel="alternate" type="text/html" title="Kubernetes, and when it earns its place" /><published>2026-09-06T08:00:00+02:00</published><updated>2026-09-06T08:00:00+02:00</updated><id>https://philipptheserver.com/posts/kubernetes</id><content type="html" xml:base="https://philipptheserver.com/posts/kubernetes/"><![CDATA[<p>I run both. A Docker cluster carries a large share of the workloads, and a Kubernetes
cluster carries the rest. People find this inconsistent. It is the most deliberate
decision in the whole estate.</p>

<p>The framing that gets everyone into trouble is that Kubernetes is what you graduate to
when Compose stops being enough. That makes it sound like a bigger version of what you
already have. It is not. It is a different bargain: you hand over control of <em>where things
run</em> and <em>when they restart</em>, and in exchange you get a system that keeps working when a
machine dies. If you do not need the second thing, you have paid for the first for nothing.</p>

<h2 id="what-you-actually-give-up">What you actually give up</h2>

<p>The thing nobody warns you about is that debugging changes shape.</p>

<p>With Compose, a service is a process on a host you can name. Something is wrong, you SSH
in, you read the logs, you see the process. The mental model is one hop deep. With
Kubernetes, “where is it running” is a question with a query attached, and the answer
changes. Between you and the process there is now a scheduler, a CNI plugin, a service
abstraction, an ingress, and a set of health checks any one of which can be the reason
nothing is responding.</p>

<p>That is not a criticism. Every one of those layers is doing something you asked for. But
it means your debugging is no longer “read the log”, it is “work out which layer is
lying”. The first few times, that takes hours, and you will feel it acutely because the
equivalent Compose problem would have taken minutes.</p>

<p>You also give up a certain kind of quick fix. On a single host, restarting a container to
clear a bad state is a legitimate move. In a cluster, whatever put the container in that
state will do it again, on a different node, at a worse time. The cluster removes your
ability to paper over things — which is good, and which does not feel good.</p>

<h2 id="what-you-get">What you get</h2>

<p><strong>Machine failure stops being an incident.</strong> This is the whole thing. On a single host, a
dead disk is a phone call. In a cluster with a real failure domain, it is a rescheduling
event you read about later. Everything else Kubernetes gives you is a consequence of the
control loop that makes this work.</p>

<p><strong>Deployment becomes declarative all the way down.</strong> With GitOps — a controller watching a
repository and reconciling the cluster toward it — the deployment story becomes identical
to the infrastructure story: the repository is the truth, and the cluster converges on it.
Nobody deploys. They merge. The cluster notices.</p>

<p>That is a genuinely different operational posture. There is no deploy script that can be
run with the wrong arguments, because there is no deploy script. There is no “did the
staging change get applied to production” because the answer is in git. Rolling back is a
revert.</p>

<p><strong>Capacity becomes fungible.</strong> Once workloads are not pinned to hosts, adding a node adds
capacity to everything at once, instead of to whichever service you decided lives there.</p>

<h2 id="when-it-does-not-earn-its-place">When it does not earn its place</h2>

<p>If you have one machine, Kubernetes gives you nothing except the layers. A single-node
cluster has the failure characteristics of a single node plus the debugging surface of a
cluster. That is the worst of both, and it is where an enormous number of installations
actually sit.</p>

<p>If your workloads are not replicable — a database with local state, something with a
license tied to a MAC address, a service that cannot tolerate being moved — then the
scheduler cannot do the thing you are paying it for. You can pin them, and people do, but
at that point you have a very elaborate way of running a process on a specific host.</p>

<p>If nobody on the team wants to learn it, it will rot. This is the one people find rude and
it is the most reliable predictor I know. Kubernetes has a real learning curve and a fast
release cadence. A cluster nobody is curious about becomes a cluster nobody upgrades,
and an unpatched cluster is a worse liability than the Compose setup it replaced.</p>

<h2 id="the-split-i-actually-run">The split I actually run</h2>

<p>So the division is not ideological, it is about what each workload needs.</p>

<p>Things that are stateless, replicable, and benefit from surviving a node failure go to the
cluster. Things that are pinned by their nature, or that are simple enough that the
cluster would only add layers, stay on Docker. The test I apply is: <em>if this host died
right now, do I want the system to handle it, or do I want to be told?</em> Both are legitimate
answers. Pretending only one is legitimate is how you end up with a database in a pod
that reschedules itself away from its disk.</p>

<p>The cost of running both is real — two deployment paths, two sets of habits. It is smaller
than the cost of forcing everything into either one.</p>

<h2 id="things-i-would-tell-myself-earlier">Things I would tell myself earlier</h2>

<p><strong>Learn the failure modes before you need them.</strong> Take a node out on purpose, on a
weekday, while you are calm. Watch what happens. The whole value proposition is behaviour
under failure, and if you have never seen it, you do not actually know whether you have it.</p>

<p><strong>Resource requests are not paperwork.</strong> They are how the scheduler makes decisions. Leave
them unset and you have asked it to pack your machines by guesswork, and it will guess
wrong under exactly the load that made you care.</p>

<p><strong>The ingress layer will surprise you.</strong> More outages come from the path into the cluster
than from anything scheduling-related. Certificates, DNS, the exact behaviour of the
controller when two things claim the same hostname — that is where the sharp edges are.</p>

<p><strong>Do not let the cluster become the place where architecture goes to be forgotten.</strong> It is
easy to add one more deployment, then another, until nobody can say what runs there or
why. The cluster does not organise your system for you. It just makes disorganisation
survive node failures.</p>

<p>Kubernetes is a good answer to a question a lot of people have not actually asked. Ask the
question first: <em>what do I want to happen when a machine dies?</em> If the honest answer is
“someone will notice and fix it, and that is fine” — then it is fine, and you have just
saved yourself a great deal of YAML.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="Kubernetes" /><category term="ArgoCD" /><category term="GitOps" /><summary type="html"><![CDATA[Kubernetes is not the next step up from Docker Compose — it is a trade, and most people are told about the benefits without being told the price. Here is what it actually bought, and what it cost.]]></summary></entry><entry><title type="html">A host you cannot rebuild is not running</title><link href="https://philipptheserver.com/posts/infrastructure-as-code/" rel="alternate" type="text/html" title="A host you cannot rebuild is not running" /><published>2026-09-06T07:00:00+02:00</published><updated>2026-09-06T07:00:00+02:00</updated><id>https://philipptheserver.com/posts/infrastructure-as-code</id><content type="html" xml:base="https://philipptheserver.com/posts/infrastructure-as-code/"><![CDATA[<p>There is a moment every sysadmin knows. Something is down, you are tired, and you can see
exactly which line in which config file would fix it. You are already logged in. Fixing it
by hand takes eleven seconds. Doing it properly — edit the repository, commit, run the
pipeline — takes four minutes.</p>

<p>Take the eleven seconds and you have just created a machine nobody can rebuild.</p>

<p>That is the whole argument for infrastructure as code, and it has very little to do with
Ansible or Terraform. It is a claim about where the truth about a system lives. Either the
repository describes the machine, or the machine does — and if it is the machine, then the
knowledge is one disk failure away from gone.</p>

<h2 id="the-rule">The rule</h2>

<p>I hold everything to one line: <strong>if a host cannot be rebuilt from the repository, it does
not count as running.</strong></p>

<p>It sounds absolute because it has to be. A rule with exceptions is not a rule, it is a
preference, and preferences lose to tiredness. The value of infrastructure as code is not
linear in how much of your infrastructure is covered. It is closer to a step function.
Ninety percent coverage gives you almost none of the benefit, because you still cannot
answer the only question that matters — <em>can I rebuild this?</em> — with yes. You have to
answer with “mostly”, and “mostly” means you will find out which ten percent was missing
at the worst possible time.</p>

<p>The practical test is not whether you <em>have</em> a playbook. It is whether you would be
willing to wipe the machine right now and run it.</p>

<h2 id="what-actually-goes-wrong">What actually goes wrong</h2>

<p>The failure mode is not that people do not write automation. Almost everyone writes
automation. The failure is <strong>drift</strong>: the code and the machine start out identical and
then quietly diverge, one eleven-second fix at a time.</p>

<p>Drift is nasty because it is invisible until you need it not to be. The playbook still
runs green. It just no longer describes reality, because reality has grown a hand-added
sysctl, a firewall rule someone opened for a debugging session in March, a package
installed to test something. None of it is written down. All of it is load-bearing by the
time you find out.</p>

<p>The defence is to make the code the only path. Not the preferred path — the only one. In
practice that means:</p>

<ul>
  <li><strong>Deployments run from a pipeline, not from a laptop.</strong> A pipeline leaves a record, uses
the committed state, and cannot be persuaded to skip a step because you are in a hurry.</li>
  <li><strong>Re-running is normal, not an event.</strong> If applying your configuration is scary, you
will not do it often, and if you do not do it often, drift accumulates between runs. A
playbook you run weekly stays honest. One you run twice a year is fiction.</li>
  <li><strong>Idempotence is not a nice property, it is the whole product.</strong> A run that reports
“changed” when nothing should have changed is telling you something, and if you have
trained yourself to ignore it, you have thrown away your only drift detector.</li>
</ul>

<p>That last point is the one people underrate. Once a playbook is genuinely idempotent, a
<code class="language-plaintext highlighter-rouge">changed=0</code> run is a <em>proof</em> — the machine matches the code, right now, verified by
execution rather than by hope. That signal is worth more than the automation itself. I
would rather have a slow, ugly, idempotent playbook than an elegant one that always
reports changes.</p>

<h2 id="dry-runs-are-not-optional">Dry runs are not optional</h2>

<p>Anything touching real infrastructure gets a dry run first. <code class="language-plaintext highlighter-rouge">--check</code>, <code class="language-plaintext highlighter-rouge">plan</code>, <code class="language-plaintext highlighter-rouge">--diff</code>,
whatever the tool calls it. Not because I expect the change to be wrong, but because the
difference between what I <em>think</em> a change does and what it <em>does</em> is exactly where
outages live.</p>

<p>There is a stronger version of this that I have come to rely on: to prove a change is only
what you think it is, run it with your change removed and confirm the tool reports <em>no
difference at all</em>. If the tool reproduces the entire existing state byte for byte, then
whatever it reports with your change back in is genuinely only your change. That turns “I
think this is safe” into something closer to a measurement.</p>

<p>It has caught things I would have sworn were fine. A refactor that was supposed to be
cosmetic, quietly changing the order of records. A default that looked inert and was not.</p>

<h2 id="what-does-not-belong-in-the-repository">What does not belong in the repository</h2>

<p>Secrets. Ever. Not encrypted-in-a-pinch, not “it is a private repo”, not base64 — which is
not encryption and everyone knows it. Secrets live in a secret store and are referenced by
name. The repository says <em>which</em> secret, never <em>what</em> it is.</p>

<p>This is worth being rigid about because the failure is unrecoverable in a specific way:
once a secret is in git history it is in every clone, every fork, every backup, and every
laptop that ever pulled. Rotating it is the only fix, and rotation is exactly the thing
nobody wants to do at the moment they discover the problem.</p>

<h2 id="where-the-code-should-not-go">Where the code should not go</h2>

<p>Infrastructure as code is not an argument for describing everything. Some things are
genuinely better as data, some as documentation, and some should not exist at all.</p>

<p>The trap is building an abstraction layer over a mess instead of removing the mess. A role
with fourteen boolean flags to accommodate four hosts that drifted apart is not
automation, it is drift with a YAML interface. The honest fix is usually to make the four
hosts the same.</p>

<p>I have written that role. Twice. The second time I noticed sooner.</p>

<h2 id="why-it-is-worth-it">Why it is worth it</h2>

<p>The pitch for infrastructure as code is normally disaster recovery, and that is real but
it undersells it. I have rebuilt a machine from scratch maybe a handful of times. I have
<em>read</em> the repository to find out how something works hundreds of times.</p>

<p>That is the actual return. A system whose configuration is written down is a system you
can reason about without logging in, hand over without a three-hour walkthrough, and
change without the specific fear that comes from not knowing what you are about to break.
Recovery is the insurance policy. Comprehensibility is what you use every day.</p>

<p>And the eleven-second fix at three in the morning? Do it, if the alternative is staying
down. Then write the four-minute version before you go to bed, while you still remember
what you did. The rule is not that you never touch a machine by hand. It is that the
machine never keeps a secret from the repository overnight.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="Ansible" /><category term="Terraform" /><category term="Linux" /><summary type="html"><![CDATA[Infrastructure as code is not a tool choice, it is a rule about where truth lives. The rule only works if you never break it — and the temptation to break it always arrives at three in the morning.]]></summary></entry><entry><title type="html">OpenTaberna, and writing the wiki first</title><link href="https://philipptheserver.com/posts/opentaberna/" rel="alternate" type="text/html" title="OpenTaberna, and writing the wiki first" /><published>2026-09-05T12:00:00+02:00</published><updated>2026-09-05T12:00:00+02:00</updated><id>https://philipptheserver.com/posts/opentaberna</id><content type="html" xml:base="https://philipptheserver.com/posts/opentaberna/"><![CDATA[<p><a href="https://github.com/OpenTaberna">OpenTaberna</a> is an open-source hospitality stack: a
FastAPI backend, a customer frontend, an admin frontend, and a wiki. Ordering, fulfilment,
payments, returns. The domain is not exotic, which is exactly why it is a good place to be
strict about how a project is put together.</p>

<p>The part worth writing about is not the API. It is the wiki.</p>

<h2 id="the-documentation-is-a-repository">The documentation is a repository</h2>

<p>The wiki lives in <a href="https://github.com/OpenTaberna/wiki">its own repository</a> as Markdown
files, and is published from there to <a href="https://wiki.opentaberna.de">wiki.opentaberna.de</a>.
It is not a wiki in the sense of a database somebody edits through a browser. It is text
under version control, and it moves through pull requests like everything else.</p>

<p>That one decision changes the character of the documentation completely.</p>

<p><strong>It can be wrong in a way that shows.</strong> A page in a hosted wiki drifts silently — there is
no diff, no blame, no review, and no moment where somebody has to look at the change. A
page in a repository is reviewed by the same person reviewing the code that made it stale.</p>

<p><strong>It can be required.</strong> If the docs are in the repository, “the docs are updated in the
same change” is a rule a reviewer can actually enforce, because the change is right there
in the diff. If the docs are somewhere else, that rule is a hope. I have watched the hope
version fail on every project that tried it, including mine.</p>

<p><strong>It can be run locally.</strong> <code class="language-plaintext highlighter-rouge">docker compose up</code> brings up the same wiki software the
published site runs, serving this repository’s pages, with no login and no setup wizard.
So a page looks locally exactly as it will look published. That sounds like a small
convenience and it is the difference between people previewing their writing and people
guessing.</p>

<h2 id="what-the-pages-have-to-cover">What the pages have to cover</h2>

<p>The structure is the interesting bit, because it maps to the questions people actually
arrive with:</p>

<ul>
  <li><strong>What is this and how is it built</strong> — the architecture and the four repositories</li>
  <li><strong>How do I run it</strong> — the whole stack, locally, in one place</li>
  <li><strong>How does authorization work</strong> — the roles, the clients, and what the API enforces</li>
  <li><strong>The API</strong> — endpoints, the response envelope, the error model</li>
  <li><strong>The database</strong> — the schema as it is actually built</li>
  <li><strong>Orders and fulfilment</strong> — the lifecycle, payments, the outbox, returns</li>
  <li><strong>Configuration</strong> — every setting and where it can come from</li>
  <li><strong>Deployment</strong> — how it runs in production</li>
</ul>

<p>Two of those deserve comment.</p>

<p><strong>“The schema as it is actually built”</strong> is a deliberate phrase. Schema documentation
drifts faster than anything else in a project, because it is written during design and
then the migrations happen. A page that promises to describe reality has to be revisited
when reality changes, and saying so in the title makes that obligation explicit.</p>

<p><strong>“Every setting and where it can come from”</strong> matters more than it sounds. Most
configuration documentation lists the settings and omits the precedence — file, environment,
default, flag — and precedence is exactly what you need at the moment configuration is not
doing what you expect. That is the only moment anyone reads the page.</p>

<h2 id="the-response-envelope-and-why-consistency-beats-cleverness">The response envelope, and why consistency beats cleverness</h2>

<p>An API that returns a bare object here and a wrapped one there, an error as a string in
one place and an object in another, is an API where every client writes its own special
cases. The special cases are where bugs live, and they are invisible until a client hits
the one endpoint that is shaped differently.</p>

<p>Deciding the envelope and the error model <strong>once</strong>, writing it on a page, and then holding
every endpoint to it is unglamorous and it is most of what makes an API pleasant. It also
makes the documentation shorter, because the shape is described once instead of per
endpoint.</p>

<p>The same argument applies to authorization. Roles and clients defined centrally, with the
API enforcing them, means “who can do this” is answerable by reading one page rather than
by grepping decorators.</p>

<h2 id="open-source-changes-the-standard">Open source changes the standard</h2>

<p>Working on something public raises the bar in a specific way: <strong>you cannot rely on
anybody knowing anything.</strong></p>

<p>There is no colleague to ask, no shared context, no “obviously it needs the database
running first”. Everything a person needs has to be written down, and the fastest way to
find out whether it is written down is that somebody tries and fails.</p>

<p>That is uncomfortable and it is the most useful review a project gets. Every internal
project I have worked on had gaps that were invisible precisely because everyone had
already been told the missing thing in a conversation. A public project has no
conversations to lean on.</p>

<p>The related discipline is repository conventions — how issues are written, what a commit
message says, what “done” means. Internally you can get away with these living in
somebody’s head. Publicly they have to be written down, and once they are written down you
notice they were never really agreed.</p>

<h2 id="what-i-would-take-to-every-project">What I would take to every project</h2>

<p>The wiki-as-repository pattern, without hesitation.</p>

<p>Documentation that is not under version control is documentation that will be wrong, and
the only question is when. Putting it in a repository does not make anybody write more —
it makes not writing visible in a diff, which turns out to be the same thing.</p>]]></content><author><name>Philipp Lehmann</name><email>philipp.lehmann@gruppe.ai</email><uri>https://philipptheserver.com</uri></author><category term="OpenTaberna" /><category term="FastAPI" /><category term="Open Source" /><category term="Documentation" /><summary type="html"><![CDATA[An open-source ordering and fulfilment system built as four repositories with a published wiki — and the thing I would take to every project after it: the documentation is a repository, reviewed like code.]]></summary></entry></feed>