<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.9.3">Jekyll</generator><link href="https://gofranz.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://gofranz.com/" rel="alternate" type="text/html" /><updated>2026-07-15T11:12:27+01:00</updated><id>https://gofranz.com/feed.xml</id><title type="html">Franz Geffke</title><subtitle>Full-Stack Developer, AI Orchestrator, ½ Machine - Personal website and blog covering web development, AI integration, system architecture, and technology insights</subtitle><entry><title type="html">claude-plugins: Domain Experts You Can Install</title><link href="https://gofranz.com/blog/claude-plugins-domain-experts-you-can-install/" rel="alternate" type="text/html" title="claude-plugins: Domain Experts You Can Install" /><published>2026-07-10T01:00:00+01:00</published><updated>2026-07-10T01:00:00+01:00</updated><id>https://gofranz.com/blog/claude-plugins-domain-experts-you-can-install</id><content type="html" xml:base="https://gofranz.com/blog/claude-plugins-domain-experts-you-can-install/"><![CDATA[<p>I’ve been accumulating Claude Code subagents for a while: a Rust engineer that actually writes idiomatic Rust, a security auditor that maps the attack surface first, a Guix specialist that knows what a G-expression is. They lived in my dotfiles and nowhere else, which is a fine place for them until you want to share them or install a subset on a fresh machine. So I packaged them up as <a rel="noopener" href="https://github.com/franzos/claude-plugins">claude-plugins</a>, a small marketplace you can add to Claude Code and pull from piecemeal.</p>

<p>The idea is simple: domain experts (subagents) grouped into installable plugins. Each plugin ships one or more agents you dispatch for a specific stack or domain. Nothing exotic, just a way to hand Claude the right specialist instead of hoping the generalist gets it right.</p>

<h2 id="installing">Installing</h2>

<p>Add the marketplace, then install only the plugins you want:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/plugin marketplace add franzos/claude-plugins
/plugin install engineers
/plugin install identity
</code></pre></div></div>

<p>Plugins are namespaced by their marketplace (this one is called <code class="language-plaintext highlighter-rouge">gofranz</code>). If another marketplace you’ve added also ships a plugin named <code class="language-plaintext highlighter-rouge">engineers</code>, qualify it to disambiguate:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/plugin install engineers@gofranz
</code></pre></div></div>

<p>And if you’d rather hack on the agents yourself, point the marketplace at a local checkout instead of the remote:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/plugin marketplace add /path/to/claude-plugins
</code></pre></div></div>

<h2 id="whats-in-the-box">What’s in the box</h2>

<p>There are a handful of plugins, split by how I use them. The <code class="language-plaintext highlighter-rouge">engineers</code> plugin is the daily driver: stack-specific implementation agents for C++, Go, Java, Next.js, Qt, React, Rust, and TypeScript. The rest are specialists that go deep in one thing rather than shallow across many, <code class="language-plaintext highlighter-rouge">identity</code>, <code class="language-plaintext highlighter-rouge">guix</code>, <code class="language-plaintext highlighter-rouge">security</code>, <code class="language-plaintext highlighter-rouge">sql</code>, <code class="language-plaintext highlighter-rouge">iota</code>, and <code class="language-plaintext highlighter-rouge">iced</code>. And <code class="language-plaintext highlighter-rouge">forseti</code> and <code class="language-plaintext highlighter-rouge">stackpit</code> are operator skills for two of my own projects, so those only matter if you run them.</p>

<p>The <a rel="noopener" href="https://github.com/franzos/claude-plugins">README</a> has the full list and what each agent covers.</p>

<h2 id="the-one-setup-step-worth-doing">The one setup step worth doing</h2>

<p>These agents run build, test, and format commands, so they need to know something about your machine. Left alone they’ll probe for what’s available and ask when unsure, which works but costs a round trip every session. Better to tell them once, in your global <code class="language-plaintext highlighter-rouge">~/.claude/CLAUDE.md</code>: Claude reads it every session and the facts reach the agents. A short <code class="language-plaintext highlighter-rouge">## Environment</code> block (OS and shell, container runtime, package manager, how you provide toolchains, and “never install system-wide packages without asking”) is enough. The README has an example to copy.</p>

<h2 id="best-practice-compose-dont-copy">Best practice: compose, don’t copy</h2>

<p>If you build your own skills on top of these, wire them to the agents that already exist instead of re-deriving what those agents know. Delegate to the matching agent rather than inlining its expertise, reference an agent by name instead of duplicating its instructions, and keep each agent focused while your skills chain them together. The payoff is the usual DRY one: fix a shared agent once and everything that references it improves. The README goes into this properly.</p>

<p>It’s <a rel="noopener" href="https://github.com/franzos/claude-plugins">on GitHub</a>. If any of those stacks are yours, install the one plugin you care about and see whether the specialist beats the generalist. In my experience it usually does, which is the whole reason these exist.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="claude-code" /><category term="plugins" /><category term="subagents" /><category term="tooling" /><summary type="html"><![CDATA[A small Claude Code marketplace of subagents grouped into installable plugins, from stack-specific engineers to single-domain specialists, plus the one setup step that makes them behave.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Chat Control 1.0 Is Back: What It Means for You</title><link href="https://gofranz.com/blog/chat-control-1-0-what-it-means-for-you/" rel="alternate" type="text/html" title="Chat Control 1.0 Is Back: What It Means for You" /><published>2026-07-09T01:00:00+01:00</published><updated>2026-07-09T01:00:00+01:00</updated><id>https://gofranz.com/blog/chat-control-1-0-what-it-means-for-you</id><content type="html" xml:base="https://gofranz.com/blog/chat-control-1-0-what-it-means-for-you/"><![CDATA[<p>Today, on the last day before summer break, the EU Parliament brought <strong>Chat Control 1.0</strong> back from the dead. I want to keep this short, because the details are exhausting and the gist is what matters. So, bullet points.</p>

<h2 id="what-actually-passed">What actually passed</h2>

<ul>
  <li>Chat Control 1.0 is the rule that <strong>lets</strong> US tech companies (Meta, Google, Microsoft) automatically scan your private messages, emails, and chats for child sexual abuse material (CSAM). No warrant, no suspicion, everyone by default.</li>
  <li>It had already <strong>expired in April</strong>, after Parliament narrowly rejected an extension in March.</li>
  <li>It came back through a procedural trick: a snap “urgent procedure” vote on Tuesday (331 to 304) skipped the normal committee review and forced a floor vote today.</li>
  <li>More MEPs actually voted <em>against</em> it than for it (<strong>314 against, 276 in favor, 17 abstentions</strong>), but blocking it needed an <strong>absolute majority of 361</strong>, and opponents couldn’t reach that. So it passes by default. A separate amendment to limit scanning to judicially identified suspects also fell short, 322 to 255.</li>
  <li>It’s now extended for roughly <strong>two years</strong>, until 2028 or until the permanent version lands.</li>
</ul>

<h2 id="what-it-means-for-you">What it means for you</h2>

<ul>
  <li>If you use Instagram, Discord, Snapchat, Gmail, or iCloud, your <strong>unencrypted</strong> private messages can be scanned again, without any individual suspicion.</li>
  <li><strong>End-to-end encrypted</strong> apps (WhatsApp, Signal) are not affected by this one. Yet. That’s the fight over “Chat Control 2.0”, the permanent, mandatory version, and negotiations on it resume in September.</li>
  <li>This covers around <strong>450 million people</strong>. You don’t get to opt out. You are scanned because you exist.</li>
</ul>

<h2 id="why-i-think-its-a-bad-deal">Why I think it’s a bad deal</h2>

<p>The thing is, it doesn’t even work. From the EU Commission’s own evaluation:</p>

<ul>
  <li>Only <strong>0.00000077%</strong> of scanned messages in the EU actually contained illegal content.</li>
  <li>False-positive rates ran as high as <strong>20%</strong>. That’s a lot of innocent people’s private photos flagged and reviewed.</li>
  <li>Around <strong>99%</strong> of Meta’s reports involve already-known material, so police get buried in noise instead of catching new abuse.</li>
</ul>

<p><a rel="noopener" href="https://www.patrick-breyer.de/en/eu-parliament-greenlights-chat-control-1-0-breyer-our-children-lose-out/">Patrick Breyer put it well</a>: trying to protect children with suspicionless mass surveillance is like frantically mopping the floor while the faucet is still running. Targeted investigations and court-ordered wiretaps catch offenders. Scanning everyone’s chats mostly catches everyone.</p>

<p>Either way, this isn’t over. 1.0 buys the surveillance camp two years; 2.0 is where they try to make it mandatory and reach into encrypted messaging too. If you’re in the EU and you care about this, September is when to pay attention.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Opinion&quot;]" /><category term="privacy" /><category term="eu" /><category term="surveillance" /><category term="encryption" /><category term="policy" /><summary type="html"><![CDATA[The EU Parliament just reactivated Chat Control 1.0. Here's the short version of what passed, what it does, and what it actually means for 450 million EU citizens.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">tku: Run Claude as Another Account, Just Once</title><link href="https://gofranz.com/blog/tku-run-claude-as-another-account/" rel="alternate" type="text/html" title="tku: Run Claude as Another Account, Just Once" /><published>2026-07-08T01:00:00+01:00</published><updated>2026-07-08T01:00:00+01:00</updated><id>https://gofranz.com/blog/tku-run-claude-as-another-account</id><content type="html" xml:base="https://gofranz.com/blog/tku-run-claude-as-another-account/"><![CDATA[<p>A while back I wrote about <a href="/blog/tku-claude-code-on-steroids/">tku</a>, my little Rust CLI for tracking token usage and swapping between my work and personal Claude subscriptions. The swap part (<code class="language-plaintext highlighter-rouge">tku account use work</code>) does a global switch: it rewrites your login and everything from then on runs as that account. Good for changing context for a while, but overkill when you just want to run one thing as the other account and keep going.</p>

<p>That’s the gap <code class="language-plaintext highlighter-rouge">tku account exec</code> fills.</p>

<h2 id="one-session-one-account-no-global-switch">One session, one account, no global switch</h2>

<p>Say I’m deep in a work session and I want to run a single Claude Code session on my personal subscription, maybe to keep the side-project tokens off the company plan. I don’t want to log out, swap, do the thing, and swap back. I just want:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tku account <span class="nb">exec </span>personal <span class="nt">--</span> claude
</code></pre></div></div>

<p>That runs <code class="language-plaintext highlighter-rouge">claude</code> as <code class="language-plaintext highlighter-rouge">personal</code> in an isolated config dir, leaving your active <code class="language-plaintext highlighter-rouge">~/.claude</code> untouched. Your current work session (and any other <code class="language-plaintext highlighter-rouge">claude</code> you have open) keeps running exactly as it was. When the exec session exits, nothing about your global login has changed.</p>

<p>Like <code class="language-plaintext highlighter-rouge">sudo</code> or <code class="language-plaintext highlighter-rouge">env</code>, it runs whatever you put after <code class="language-plaintext highlighter-rouge">--</code>. It doesn’t launch <code class="language-plaintext highlighter-rouge">claude</code> for you, so a one-shot prompt works the same way:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tku account <span class="nb">exec </span>personal <span class="nt">--</span> claude <span class="nt">-p</span> <span class="s2">"summarise this repo"</span>
</code></pre></div></div>

<p>Or drop into a shell with the right <code class="language-plaintext highlighter-rouge">CLAUDE_CONFIG_DIR</code> already set, and run your own launcher from there:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tku account <span class="nb">exec </span>personal <span class="nt">--</span> bash <span class="nt">-i</span>
</code></pre></div></div>

<h2 id="what-it-does-under-the-hood">What it does under the hood</h2>

<p>It seeds a private dir from the account’s stashed credentials, then symlinks your shared <code class="language-plaintext highlighter-rouge">skills/</code>, <code class="language-plaintext highlighter-rouge">plugins/</code>, <code class="language-plaintext highlighter-rouge">agents/</code>, <code class="language-plaintext highlighter-rouge">commands/</code>, and <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> so the session behaves like your normal setup, just with a different login. It copies and patches <code class="language-plaintext highlighter-rouge">.claude.json</code> and <code class="language-plaintext highlighter-rouge">settings.json</code>, and syncs any refreshed credentials back to the stash on exit. The dir lives under <code class="language-plaintext highlighter-rouge">$XDG_RUNTIME_DIR</code> (tmpfs, cleared on logout), never in your persistent config.</p>

<p>A few flags if you want to shape that: <code class="language-plaintext highlighter-rouge">--ephemeral</code> for a throwaway dir deleted on exit, <code class="language-plaintext highlighter-rouge">--clean</code> for a bare instance with none of the shared skills/plugins, and <code class="language-plaintext highlighter-rouge">--copy</code> to copy the shared dirs instead of symlinking them.</p>

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

<ul>
  <li>Token usage inside an <code class="language-plaintext highlighter-rouge">exec</code> session is written to the isolated dir, so it does not show up in <code class="language-plaintext highlighter-rouge">tku</code> reports. That’s the trade for isolation.</li>
  <li>It refuses to run if the account is already live, whether as your active login or another <code class="language-plaintext highlighter-rouge">exec</code>. Claude’s OAuth refresh tokens are single-use, so two live sessions on one login invalidate each other and brick both. To run two of the same account at once, add a second login with fresh credentials as a separate stash entry.</li>
  <li>Credentials have to be file-based (<code class="language-plaintext highlighter-rouge">~/.claude/.credentials.json</code>), so this is Linux, not macOS, where Claude keeps them in the Keychain that <code class="language-plaintext highlighter-rouge">CLAUDE_CONFIG_DIR</code> doesn’t relocate.</li>
</ul>

<p>It turns out this is the account command I reach for most now. The global swap is for changing gears; <code class="language-plaintext highlighter-rouge">exec</code> is for the quick detour without leaving the road you’re on.</p>

<p>It’s <a rel="noopener" href="https://github.com/franzos/tku">on GitHub</a>. If you juggle more than one Claude subscription the way I do, it’s a nice one to have.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="tku" /><category term="rust" /><category term="claude-code" /><category term="cli" /><category term="tooling" /><summary type="html"><![CDATA[A one-shot exec that runs Claude Code under a different subscription without touching your active login, so your work session keeps going while you fire off one thing as your personal account.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Still at it: PantherX, four years on</title><link href="https://gofranz.com/blog/still-at-it-pantherx-as-a-guix-overlay/" rel="alternate" type="text/html" title="Still at it: PantherX, four years on" /><published>2026-07-05T02:00:00+01:00</published><updated>2026-07-05T02:00:00+01:00</updated><id>https://gofranz.com/blog/still-at-it-pantherx-as-a-guix-overlay</id><content type="html" xml:base="https://gofranz.com/blog/still-at-it-pantherx-as-a-guix-overlay/"><![CDATA[<p>Four years ago I wrote a post called <a href="/blog/all-at-once-i-made-a-huge-mistake/">All at once: I made a huge mistake</a>. The short version: we set out to build a whole distribution from scratch, tried to do too much ourselves, and I eventually admitted that the best path was to stop reinventing the wheel and lean on <a rel="noopener" href="https://guix.gnu.org/">Guix</a> instead. Move the whole system into one repository, apply changes with inheritance, revert a bad release in half a second. That part still resonates.</p>

<p>What I didn’t have a good answer for back then was the gap between “Guix is the right foundation” and “a normal person, or a company, can actually run this.” Guix is excellent on the command line and fairly unforgiving off it. So the last stretch has been less about grand vision and more about filling that gap, piece by piece. PantherX was always a layer on top of Guix; what’s changed is that we’ve stopped trying to build everything ourselves and started <strong>picking the high-value pieces</strong> instead: the parts that improve the day-to-day experience for an end user, make Guix more approachable, and make it more attractive for enterprise. The same Guix underneath, with the rough edges sanded down.</p>

<p>Here’s where things stand.</p>

<h2 id="a-gui-installer">A GUI installer</h2>

<p>Installing an OS from a text prompt isn’t for everyone. The keyboard-only TTY installer worked, but it was a wall for anyone who didn’t already live in a terminal. So there’s now a <a rel="noopener" href="https://github.com/franzos/guix-install">graphical installer</a> that walks you through the same phases (locale, timezone, disk, encryption, users, desktop) in a window, streams the build output with a progress bar, and keeps the resume-on-failure and atomic-write behaviour from the original. It installs plain Guix, Nonguix, or PantherX. Same install, just driven from a window.</p>

<p>There’s also a fourth mode I care about for the enterprise angle: point it at a config server with <code class="language-plaintext highlighter-rouge">--config &lt;id&gt;</code> and it fetches a predefined system definition (a <code class="language-plaintext highlighter-rouge">system.scm</code>, optionally a <code class="language-plaintext highlighter-rouge">channels.scm</code> and a <code class="language-plaintext highlighter-rouge">config.json</code>) as a tarball over HTTPS and installs that verbatim. The interview collapses to just disk and encryption; everything else (packages, services, users, desktop) comes from the central config. So a shop can define one machine, host it, and stamp out a fleet of identical ones by ID. That’s the central-repository argument from the old post, applied at install time. The <a rel="noopener" href="https://wiki.pantherx.org/">latest ISO</a> ships both the GUI and the TTY installer, so you can pick. (<a rel="noopener" href="https://www.pantherx.org/news/2026/guix-install-gets-a-gui.html">write-up</a>)</p>

<figure style="margin:1.5rem 0">
  <a href="/assets/images/blog/guix-install-gui.png"><img src="/assets/images/blog/guix-install-gui.png" alt="The guix-install GUI mid-install" style="width:100%;border-radius:4px" /></a>
  <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">The installer mid-run: phases tick off down the sidebar, with live build output and a progress bar. Click to enlarge.</figcaption>
</figure>

<h2 id="gui-package-and-channel-management">GUI package and channel management</h2>

<p>The other half of the daily-driver problem is what happens after install. <code class="language-plaintext highlighter-rouge">guix search</code>, <code class="language-plaintext highlighter-rouge">guix install</code>, <code class="language-plaintext highlighter-rouge">guix upgrade</code> and channel edits aren’t where most people want to spend their day. There’s now a <a rel="noopener" href="https://github.com/franzos/guix-rs">graphical package manager</a> for browsing and searching packages, installing and removing them, running <code class="language-plaintext highlighter-rouge">guix pull</code> and reconfigure (via polkit, only for the privileged bits), and managing channels with backup and restore. It ships in seven languages and follows your locale. Anything against your own profile runs unprivileged. (<a rel="noopener" href="https://www.pantherx.org/news/2026/a-gui-for-everyday-guix.html">write-up</a>)</p>

<figure style="margin:1.5rem 0">
  <a href="/assets/images/blog/guix-gui-package-manager.png"><img src="/assets/images/blog/guix-gui-package-manager.png" alt="The Guix GUI package manager home screen" style="width:100%;border-radius:4px" /></a>
  <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">The home screen: well-known apps grouped by category as a starting point, with search for the full catalogue. Click to enlarge.</figcaption>
</figure>

<h2 id="sso-for-linux">SSO for Linux</h2>

<p>This is the one I’m most pleased with, because it’s the part that makes Guix interesting beyond a single machine. <a rel="noopener" href="https://github.com/franzos/forseti">Forseti</a> now backs Linux logins off a central identity store: NSS <code class="language-plaintext highlighter-rouge">passwd</code>/<code class="language-plaintext highlighter-rouge">group</code>, per-user SSH keys, interactive <code class="language-plaintext highlighter-rouge">ssh</code> and console login through the OAuth Device Authorization Grant, and an offline passphrase fallback for when the server’s unreachable. The system PAM module ships via the <a rel="noopener" href="https://github.com/franzos/panther">panther channel</a>. I wrote more about Forseti itself <a href="/blog/forseti-the-web-ui-ory-doesnt-ship/">here</a>; the point for this post is the fit. Centrally managed Linux with SSO isn’t new ground, there are other options, but most are generic. Forseti is built with Guix in mind, so it slots into this setup more naturally than a generic IdP would, and pairs with the central-config install above.</p>

<h2 id="a-more-stable-substitute-server">A more stable substitute server</h2>

<p>Less glamorous, more load-bearing. The channel moved to <a rel="noopener" href="https://codeberg.org/gofranz/panther">Codeberg</a>, and there’s a new substitute server at <code class="language-plaintext highlighter-rouge">substitutes.guix.gofranz.com</code>: a small build farm on a ThinkPad in my office, fronted by a CDN and driven by Cuirass. It builds the Panther channel’s own packages, so those come down prebuilt as substitutes instead of compiling on your machine. Authorize the key, point your substitutes at it, done (and on PantherX it’s already wired in). (<a rel="noopener" href="https://www.pantherx.org/news/2026/codeberg-and-new-substitutes.html">write-up</a>)</p>

<h2 id="a-channel-thats-actually-filling-out">A channel that’s actually filling out</h2>

<p>The <a rel="noopener" href="https://codeberg.org/gofranz/panther">channel</a> is north of 200 packages now, and a good chunk of them are the proprietary apps people actually reach for but that upstream Guix won’t ship. Guix holds the free-software line, Panther carries the nonfree pieces, both in one place. A few of the fast-moving ones:</p>

<ul>
  <li><strong>Editors and AI</strong>: VS Code, Cursor, Zed; Claude Code, Claude Desktop, Codex, Ollama.</li>
  <li><strong>Desktop apps</strong>: Zoom, Slack, Discord, RustDesk, Bitwig Studio, Tidal, Thorium, Ghostty.</li>
  <li><strong>Networking and VPNs</strong>: Tailscale, Mullvad, Nebula, IVPN, ngrok.</li>
  <li><strong>Dev tooling</strong>: gh, GitButler, jj, bun, pnpm, Biome, OpenTofu, DBeaver.</li>
</ul>

<h2 id="services-not-just-packages">Services, not just packages</h2>

<p>Packaging an app is half the job; wiring it into the system is the other half. So the channel also ships a growing set of Guix services, the point being that you declare these in your <code class="language-plaintext highlighter-rouge">system.scm</code> (or per-user in <code class="language-plaintext highlighter-rouge">home.scm</code>) instead of hand-rolling shepherd scripts. On the server side:</p>

<ul>
  <li><strong>Forseti</strong>, with Ory Hydra and Kratos: the SSO stack from above, ready to reconfigure into place.</li>
  <li><strong>bichon</strong>, a small mail archiver with full-text search and a web UI.</li>
  <li>networking daemons: Nebula overlay mesh, Tailscale, Mullvad, plus usbguard</li>
</ul>

<p>And a handful of <a rel="noopener" href="https://guix.gnu.org/manual/en/html_node/Home-Configuration.html">Guix Home</a> services for the per-user side: foot terminal config, per-user mail archiving, unattended upgrades, and podman healthchecks. Same central-config story as the installer: describe the machine once, services and all, and reproduce it wherever you need it.</p>

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

<p>Put together, the shape is clearer than it’s ever been. PantherX isn’t trying to be a better OS than Guix. It’s the layer that makes Guix approachable: install it without a terminal, manage it without memorizing subcommands, and centrally configure a fleet of Linux machines with proper single sign-on, using one repository as the source of truth. Everyone benefits from a fix to one file, which was the whole argument in 2022. It just took building the accessible front for it to matter.</p>

<p>This is still ongoing, and plenty is rough or pre-release. But it no longer feels like I’m pushing a boulder uphill. It feels like the pieces are finally lining up. Do take that with a grain of salt, as always, and if any of it sounds useful, it’s all at <a rel="noopener" href="https://www.pantherx.org/">pantherx.org</a>.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Linux&quot;, &quot;Tools&quot;]" /><category term="guix" /><category term="pantherx" /><category term="linux" /><category term="self-hosted" /><summary type="html"><![CDATA[Four years after some wrong turns dragged PantherX out longer than it should have, it's found a shape that works: a layer on top of Guix that makes it approachable, with a GUI installer, GUI package management, real SSO for Linux, and a more stable substitute server.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Stackpit: a Tiny Self-Hosted Sentry</title><link href="https://gofranz.com/blog/stackpit-a-tiny-self-hosted-sentry/" rel="alternate" type="text/html" title="Stackpit: a Tiny Self-Hosted Sentry" /><published>2026-07-04T01:00:00+01:00</published><updated>2026-07-04T01:00:00+01:00</updated><id>https://gofranz.com/blog/stackpit-a-tiny-self-hosted-sentry</id><content type="html" xml:base="https://gofranz.com/blog/stackpit-a-tiny-self-hosted-sentry/"><![CDATA[<p>Self-hosting the official Sentry means standing up PostgreSQL, ClickHouse, Kafka, and Redis before it will even boot. That’s a lot of infrastructure to run and keep healthy, and for most of what I use error tracking for, it’s more backend than the job needs. What I actually want is ingestion, grouping, and a way to browse errors.</p>

<p>So I built <strong>Stackpit</strong>: a drop-in, self-hosted replacement for Sentry’s event ingestion and browsing. One binary, one SQLite file, no external dependencies. Point your existing Sentry SDKs at it, browse errors in the web UI, or query the JSON API.</p>

<p>“Lighter” here means lighter to run, not lighter in what it can take. On a single laptop with the default SQLite backend it sustains around 10,800 events/s with zero rejections; tune the write batch and it holds well into the tens of thousands, and PostgreSQL fans ingestion out across writers for another 2-3x on top. So it’s a fine fit for large projects too, not just small ones, and I’m still working to push that ceiling higher. More on the numbers below.</p>

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

<p>You get a server-rendered web UI to browse issues, events, transactions, logs, traces, and releases. No SPA, no build step, just pages. Click any screenshot to enlarge.</p>

<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin:1.5rem 0">
  <figure style="margin:0">
    <a href="/assets/images/blog/stackpit-issue-detail.png"><img src="/assets/images/blog/stackpit-issue-detail.png" alt="Stackpit issue detail with stacktrace" style="width:100%;border-radius:4px" /></a>
    <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">Issue detail: fingerprint grouping, stack trace (source maps resolved on upload), tag facets.</figcaption>
  </figure>
  <figure style="margin:0">
    <a href="/assets/images/blog/stackpit-projects.png"><img src="/assets/images/blog/stackpit-projects.png" alt="Stackpit project dashboard" style="width:100%;border-radius:4px" /></a>
    <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">Issues, events, and release health across every project.</figcaption>
  </figure>
  <figure style="margin:0">
    <a href="/assets/images/blog/stackpit-releases.png"><img src="/assets/images/blog/stackpit-releases.png" alt="Stackpit releases with adoption" style="width:100%;border-radius:4px" /></a>
    <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">Releases with adoption and crash-free rates, to spot a bad deploy.</figcaption>
  </figure>
</div>

<h2 id="how-fast-is-it">How fast is it?</h2>

<p>Throughput hasn’t been the thing I worry about. Here’s one run on a laptop (Ryzen 5 7640U, SQLite backend, ~2.9 KiB error envelopes, with the load generator stealing the same cores):</p>

<p><img src="/assets/images/blog/stackpit-benchmark.svg" alt="Stackpit ingestion benchmark" /></p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">ingest_batch_size</code></th>
      <th>Sustained (5 min, zero rejections)</th>
      <th>Burst</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>2000 (default)</td>
      <td>~10,800 events/s</td>
      <td>12,000-13,000 events/s</td>
    </tr>
    <tr>
      <td>10000</td>
      <td>~15,000-18,000 events/s</td>
      <td>~20,700 events/s</td>
    </tr>
  </tbody>
</table>

<p>The default config holds 10,800 events/s for a five-minute soak with zero rejections, accept latency p50 1.7 ms / p99 6.9 ms. Push past the knee and it sheds the excess with HTTP 503 backpressure rather than dropping anything silently. Raising the write-batch size trades a bigger all-or-nothing commit for more headroom, into the low 20,000s in bursts. And that’s SQLite with a single writer: on PostgreSQL, ingestion fans out across writer tasks for another 2-3x.</p>

<p>These are single-machine numbers, so take them with a grain of salt. The repo ships the <code class="language-plaintext highlighter-rouge">stackpit-bench</code> load generator if you want to run your own.</p>

<h2 id="whats-in-the-box">What’s in the box</h2>

<ul>
  <li><strong>Drop-in Sentry protocol.</strong> Envelope and legacy store endpoints, all auth methods. Any Sentry SDK works, no code changes. You just swap the DSN.</li>
  <li><strong>Single binary, no dependencies.</strong> One process, one SQLite file, or PostgreSQL instead if you’d rather. It’s one or the other, picked up front: there’s no migration between them.</li>
  <li><strong>Issue grouping.</strong> Fingerprint-based, with regressions and resolution tracking.</li>
  <li><strong>Some performance and tracing.</strong> Transaction percentiles, throughput, failure rates, span waterfalls, Web Vitals, and release-health crash-free rates. Basic, but useful.</li>
  <li><strong>Notifications and alerts.</strong> Email (Lettermint, Postmark, or SendGrid), Slack, and webhooks, with digests and threshold rules.</li>
  <li><strong>Source maps.</strong> Upload via <code class="language-plaintext highlighter-rouge">sentry-cli</code> so minified traces resolve to original source.</li>
  <li><strong>Cron monitors.</strong> Check-in tracking via Sentry’s protocol.</li>
  <li><strong>Auth your way.</strong> A shared admin token for solo use, or OAuth/OIDC SSO for teams, with self-serve organizations and roles.</li>
  <li><strong>Migrate in.</strong> Pull historical events, issues, and releases from an existing Sentry instance.</li>
</ul>

<h2 id="stackpit-vs-sentry">Stackpit vs Sentry</h2>

<p>Stackpit covers the everyday error-tracking workflow and a bit of performance monitoring, then stops short of Sentry’s heavier features. Here’s roughly how they line up (accurate as of today):</p>

<table>
  <thead>
    <tr>
      <th>Capability</th>
      <th>Stackpit</th>
      <th>Sentry</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Error ingestion (Sentry protocol, any SDK)</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Issue grouping, regressions, resolution</td>
      <td>Yes (own fingerprinting)</td>
      <td>Yes (richer heuristics)</td>
    </tr>
    <tr>
      <td>Source maps</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Releases and release health (crash-free users/sessions)</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Performance monitoring</td>
      <td>Basic (percentiles, throughput, failure rate)</td>
      <td>Full APM</td>
    </tr>
    <tr>
      <td>Distributed tracing</td>
      <td>Basic (span waterfalls)</td>
      <td>Full</td>
    </tr>
    <tr>
      <td>Web Vitals</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Logs</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Cron monitors</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Session replay</td>
      <td>Stores/browses what the SDK sends</td>
      <td>Full player</td>
    </tr>
    <tr>
      <td>Profiling</td>
      <td>View-only</td>
      <td>Full</td>
    </tr>
    <tr>
      <td>Alerts (email, Slack, webhook, digests, thresholds)</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Auth / SSO</td>
      <td>Admin token + OAuth/OIDC</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Organizations and roles</td>
      <td>Self-serve orgs, per-org scoping, owner/member, invites, IdP claim mapping</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>Deployment</td>
      <td>Single binary, one SQLite file</td>
      <td>Many services (PostgreSQL, ClickHouse, Kafka, Redis)</td>
    </tr>
    <tr>
      <td>Storage backend</td>
      <td>SQLite or PostgreSQL</td>
      <td>PostgreSQL + ClickHouse + Kafka</td>
    </tr>
    <tr>
      <td>License</td>
      <td>MIT, self-hosted</td>
      <td>SaaS or heavy self-host</td>
    </tr>
  </tbody>
</table>

<p>The gaps are deliberate: if deep APM, full-fidelity replay, or profiling are load-bearing for you, run the real thing.</p>

<h2 id="running-it">Running it</h2>

<p>It’s a single self-contained binary (or a Docker image), so you can… just run it: <code class="language-plaintext highlighter-rouge">stackpit init</code> writes a config with a fresh admin token, then <code class="language-plaintext highlighter-rouge">stackpit serve</code> starts it. The full walkthrough, the Docker variants, and the other install routes (<code class="language-plaintext highlighter-rouge">.deb</code>, <code class="language-plaintext highlighter-rouge">.rpm</code>, Homebrew, <code class="language-plaintext highlighter-rouge">cargo install stackpit</code>, Guix) live in the <a rel="noopener" href="https://github.com/franzos/stackpit#running">README</a>, so I don’t have to keep two copies in sync.</p>

<h2 id="where-to-get-it">Where to get it</h2>

<p>There’s a <a href="/software/stackpit/">product page</a> with the highlights, and it’s MIT-licensed with the code, releases, and docs on GitHub at <a rel="noopener" href="https://github.com/franzos/stackpit">github.com/franzos/stackpit</a>. The README has the full comparison table, and the Operator Guide covers everything past first boot: PostgreSQL, OIDC/SSO, connecting SDKs, notifications, source maps, monitors, and syncing from an existing Sentry instance.</p>

<p>That’s the trade I was after: robust enough to sit in front of a real workload, but boring to set up and boring to run. No cluster to stand up, nothing to babysit, just a binary and a config file.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="rust" /><category term="sentry" /><category term="self-hosted" /><category term="error-tracking" /><summary type="html"><![CDATA[A drop-in, self-hosted replacement for Sentry's event ingestion and browsing. Single binary, single SQLite file, no external dependencies.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Web Rendering in Iced: A Few Months On</title><link href="https://gofranz.com/blog/web-rendering-in-iced-a-few-months-on/" rel="alternate" type="text/html" title="Web Rendering in Iced: A Few Months On" /><published>2026-07-03T01:00:00+01:00</published><updated>2026-07-03T01:00:00+01:00</updated><id>https://gofranz.com/blog/web-rendering-in-iced-a-few-months-on</id><content type="html" xml:base="https://gofranz.com/blog/web-rendering-in-iced-a-few-months-on/"><![CDATA[<p>Back in February I wrote about <a href="/blog/web-rendering-in-iced-what-actually-works/">rendering web content in Iced</a> across four backends: litehtml, Blitz, Servo, and CEF. A few months of poking at <a rel="noopener" href="https://github.com/franzos/iced_webview_v2">iced_webview</a> later, enough has changed that it’s worth a short update.</p>

<h2 id="blitz-went-gpu-native">Blitz went GPU-native</h2>

<p>The February post made a big deal of two rendering paths: an image-handle path (litehtml, Blitz) where the engine draws the whole document to a pixel buffer and the widget scrolls it, and a shader path (Servo, CEF) where the engine owns its viewport and hands frames to the GPU. That split is collapsing.</p>

<p>Blitz now rasterizes directly onto iced’s own <code class="language-plaintext highlighter-rouge">wgpu</code> device via a shared Vello renderer. No CPU readback, no viewport-sized buffer clone per frame — Vello draws straight into iced’s texture. It also owns its own scrolling now, applies <code class="language-plaintext highlighter-rouge">:hover</code> styles before paint, and does drag-selection. So Blitz moved from the “just a layout library” camp toward behaving like a real engine, while staying pure Rust. That’s the change I’m most happy about.</p>

<p>The catch is it only works on iced <code class="language-plaintext highlighter-rouge">master</code> (wgpu 29) paired with a Blitz alpha, so it lives on a separate branch (<code class="language-plaintext highlighter-rouge">next</code>) rather than the crates.io release. Bleeding edge, but it works:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[dependencies]</span>
<span class="nn">iced_webview_v2</span> <span class="o">=</span> <span class="p">{</span> <span class="py">git</span> <span class="p">=</span> <span class="s">"https://github.com/franzos/iced_webview_v2"</span><span class="p">,</span> <span class="py">branch</span> <span class="p">=</span> <span class="s">"next"</span><span class="p">,</span> <span class="py">default-features</span> <span class="p">=</span> <span class="kc">false</span><span class="p">,</span> <span class="py">features</span> <span class="p">=</span> <span class="nn">["blitz"]</span> <span class="p">}</span>
<span class="nn">iced</span> <span class="o">=</span> <span class="p">{</span> <span class="py">git</span> <span class="p">=</span> <span class="s">"https://github.com/iced-rs/iced"</span><span class="p">,</span> <span class="py">features</span> <span class="p">=</span> <span class="p">[</span><span class="s">"advanced"</span><span class="p">,</span> <span class="s">"image"</span><span class="p">,</span> <span class="s">"tokio"</span><span class="p">,</span> <span class="s">"lazy"</span><span class="p">,</span> <span class="s">"wgpu"</span><span class="p">]</span> <span class="p">}</span>
</code></pre></div></div>

<h2 id="hidpi-stopped-fighting-me">HiDPI stopped fighting me</h2>

<p>On my machines the last post’s builds were subtly wrong on HiDPI displays: content stretched vertically by the scale factor, Servo rendering at 2x and upscaling (so everything was soft), text blitting through a bilinear resample instead of 1:1. I’d been papering over it with a manual <code class="language-plaintext highlighter-rouge">set_scale_factor</code>.</p>

<p>That’s all gone. The scale factor is auto-detected across every engine now, content blits 1:1 with the surface, and Servo paints a physical-resolution buffer. Text is crisp. It turns out most of “why does this look blurry” was me applying the display scale twice in different places.</p>

<h2 id="a-security-and-soundness-pass">A security and soundness pass</h2>

<p>Remote HTML is hostile input, and I’d been treating the fetch pipeline like it wasn’t. So: download size limits are enforced while streaming instead of after buffering the whole body, images per page are capped, litehtml’s render height is clamped so a hostile page can’t exhaust memory, and a malformed <code class="language-plaintext highlighter-rouge">@import</code> at the end of a stylesheet no longer panics. The litehtml container access got reworked to raw-pointer provenance too, which closed an aliasing soundness hole that had been quietly sitting there. The SSRF exposure in the fetcher is documented rather than fixed, so treat that as a known edge for now.</p>

<h2 id="servo-hit-a-version-wall">Servo hit a version wall</h2>

<p>I upgraded Servo from 0.1 to 0.3 and Blitz to its 0.3 alpha, and the two no longer coexist. Both depend on Stylo, and the versions Blitz-on-iced-master and Servo each need now conflict at the <code class="language-plaintext highlighter-rouge">links</code> level, so Cargo won’t build them together. On the GPU-Blitz branch, Servo is temporarily disabled until it catches up to the newer Stylo.</p>

<p>So the four-backend story from February is really “pick your tradeoff” right now: crates.io release with the stable set, or the <code class="language-plaintext highlighter-rouge">next</code> branch with GPU Blitz but no Servo.</p>

<p>A couple of people pitched in on this round, which I appreciate. <a rel="noopener" href="https://github.com/nicoburns">Nico Burns</a>, who works on Blitz and Taffy upstream, handled the <a rel="noopener" href="https://github.com/franzos/iced_webview_v2/pull/4">Servo and Blitz version upgrades</a> (<a rel="noopener" href="https://github.com/franzos/iced_webview_v2/pull/2">and the earlier one</a>) that the version wall above is all about. <a rel="noopener" href="https://github.com/folknor">folknor</a> <a rel="noopener" href="https://github.com/franzos/iced_webview_v2/pull/1">fixed a scale-factor and a close-view panic</a>.</p>

<p>It’s still early work and the dependencies underneath it are all moving, but it’s moving in the right direction. Repo’s <a rel="noopener" href="https://github.com/franzos/iced_webview_v2">on GitHub</a> if you want to follow along.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="rust" /><category term="iced" /><category term="gui" /><category term="webview" /><summary type="html"><![CDATA[A progress update on iced_webview. Blitz went GPU-native, HiDPI stopped fighting me, and Servo hit a version wall.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Forseti: the Web UI Ory Doesn’t Ship</title><link href="https://gofranz.com/blog/forseti-the-web-ui-ory-doesnt-ship/" rel="alternate" type="text/html" title="Forseti: the Web UI Ory Doesn’t Ship" /><published>2026-06-27T01:00:00+01:00</published><updated>2026-06-27T01:00:00+01:00</updated><id>https://gofranz.com/blog/forseti-the-web-ui-ory-doesnt-ship</id><content type="html" xml:base="https://gofranz.com/blog/forseti-the-web-ui-ory-doesnt-ship/"><![CDATA[<p>Ory’s engines are excellent, and completely headless. Stand up <a rel="noopener" href="https://www.ory.sh/kratos/">Kratos</a> for identity and <a rel="noopener" href="https://www.ory.sh/hydra/">Hydra</a> for OAuth2/OIDC and you get certified, battle-tested auth with a clean API and nothing your users can actually look at. Login, registration, recovery, MFA, the OAuth2 consent screen: all of it is your problem. The docs hand you an example UI and wish you luck. So every team that picks Ory ends up writing the same frontend from scratch, and then owning it forever.</p>

<p>I got tired of that, so I built <strong>Forseti</strong>: the part Ory never shipped. One server-rendered Rust binary that talks to Kratos and Hydra and gives your users real screens for every flow, plus an admin console for operators. It’s not another identity engine. Rauthy, Kanidm, Keycloak all <em>are</em> the engine; Forseti sits in front of engines that are already OpenID-certified and lets you keep them.</p>

<p>That framing matters, because it’s the usual complaint about Ory (great APIs, no face) turned into a thing you can just run. You keep Kratos and Hydra doing what they’re good at, and Forseti is the front door: login and consent, the whole settings hub, an admin surface, and multi-tenant organizations on top.</p>

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

<p>Server-rendered pages, no SPA, no build step. A self-service dashboard and settings hub for users, and an admin console for operators. Click any screenshot to enlarge.</p>

<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1rem;margin:1.5rem 0">
  <figure style="margin:0">
    <a href="/assets/images/blog/forseti-dashboard.png"><img src="/assets/images/blog/forseti-dashboard.png" alt="Forseti self-service dashboard" style="width:100%;border-radius:4px" /></a>
    <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">The user's dashboard: account health (email, MFA, sessions, linked providers), quick actions, and recent sign-ins.</figcaption>
  </figure>
  <figure style="margin:0">
    <a href="/assets/images/blog/forseti-client-picker.png"><img src="/assets/images/blog/forseti-client-picker.png" alt="Forseti admin OAuth2 client picker" style="width:100%;border-radius:4px" /></a>
    <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">Admin: new OAuth2 client. Pick an app type or a pre-filled template, so you don't land on a broken combination.</figcaption>
  </figure>
  <figure style="margin:0">
    <a href="/assets/images/blog/forseti-settings.png"><img src="/assets/images/blog/forseti-settings.png" alt="Forseti account settings hub" style="width:100%;border-radius:4px" /></a>
    <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">The settings hub: profile, password, 2FA, sessions, authorized apps, linked providers, and account deletion.</figcaption>
  </figure>
</div>

<h2 id="whats-in-the-box">What’s in the box</h2>

<ul>
  <li><strong>Every Kratos flow, server-rendered.</strong> Login, registration, recovery, verification, and the full settings hub: profile, password, MFA/TOTP, passkeys, social logins, active sessions. Nothing left as raw JSON for you to render.</li>
  <li><strong>An OAuth2 / OIDC bridge.</strong> Login, consent, and logout screens for Hydra’s authorization-code flow. That turns your Ory setup into a drop-in OIDC provider for your own apps.</li>
  <li><strong>App templates for popular self-hosted apps.</strong> One-click, pre-filled OAuth2 client setup for GitLab, Nextcloud, Vaultwarden, Grafana, Immich, and dozens more, with the redirect URIs and per-app OIDC quirks already filled in. Wiring up a new app stops being a scavenger hunt through its docs.</li>
  <li><strong>An admin console.</strong> Manage identities, sessions, and OAuth2 clients; append-only audit log; live status dashboard; dynamic-client-registration tokens.</li>
  <li><strong>Organizations.</strong> Multi-tenant orgs with members, invites, per-org branding (colors, logo, a public <code class="language-plaintext highlighter-rouge">/o/{slug}</code> landing page), and per-org OIDC claims, so one deployment serves many tenants.</li>
  <li><strong>Linux host auth (preview).</strong> Back your Linux logins off the identity store: NSS <code class="language-plaintext highlighter-rouge">passwd</code>/<code class="language-plaintext highlighter-rouge">group</code> plus per-user SSH-key distribution, interactive <code class="language-plaintext highlighter-rouge">ssh</code>/console login via the OAuth Device Authorization Grant (RFC 8628), and an offline passphrase login for when the server’s unreachable.</li>
  <li><strong>The boring, load-bearing parts.</strong> CSRF on every form, signed cookies, rate-limited DCR, safe response headers, and an account-deletion webhook saga with retries that emits signed RISC events.</li>
  <li><strong>Light and dark.</strong> A built-in theme toggle (light / dark / follow-system) on every page, plus three built-in themes tenants can pick from.</li>
</ul>

<h2 id="forseti-vs-the-identity-crowd">Forseti vs the identity crowd</h2>

<p>This comparison is a little apples-to-oranges, and that’s rather the point. Rauthy, Kanidm, Keycloak and FreeIPA each bring their own protocol stack and datastore; they <em>are</em> the engine. Forseti is the UI, admin console, orgs, and governance layer sitting in front of Ory. So the question isn’t “which engine wins”, it’s “if you’ve already bet on Ory, what gives it a face”. Accurate as of mid-2026:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th style="text-align: center"><strong>Forseti</strong></th>
      <th style="text-align: center"><strong>Rauthy</strong></th>
      <th style="text-align: center"><strong>Kanidm</strong></th>
      <th style="text-align: center"><strong>Keycloak</strong></th>
      <th style="text-align: center"><strong>FreeIPA</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>What it is</strong></td>
      <td style="text-align: center">UI + governance on Ory</td>
      <td style="text-align: center">Standalone OIDC provider</td>
      <td style="text-align: center">Passkey-first IdM</td>
      <td style="text-align: center">Full IAM server</td>
      <td style="text-align: center">Linux/Unix domain IdM</td>
    </tr>
    <tr>
      <td><strong>Language</strong></td>
      <td style="text-align: center">Rust (Axum)</td>
      <td style="text-align: center">Rust</td>
      <td style="text-align: center">Rust</td>
      <td style="text-align: center">Java / JVM</td>
      <td style="text-align: center">C + Python</td>
    </tr>
    <tr>
      <td><strong>OIDC / OAuth2 provider</strong></td>
      <td style="text-align: center">Yes (Hydra)</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Inbound only</td>
    </tr>
    <tr>
      <td><strong>TOTP + passkeys</strong></td>
      <td style="text-align: center">Yes (AAL2-enforced)</td>
      <td style="text-align: center">Passkey-first</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
    </tr>
    <tr>
      <td><strong>Multi-org / tenancy</strong></td>
      <td style="text-align: center">Yes †</td>
      <td style="text-align: center">No</td>
      <td style="text-align: center">No</td>
      <td style="text-align: center">Realms + orgs</td>
      <td style="text-align: center">No</td>
    </tr>
    <tr>
      <td><strong>Social login / IdP brokering</strong></td>
      <td style="text-align: center">Yes (Kratos)</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">No, by design</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Limited</td>
    </tr>
    <tr>
      <td><strong>Admin console (web)</strong></td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">CLI-first</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
    </tr>
    <tr>
      <td><strong>End-user self-service UI</strong></td>
      <td style="text-align: center">Yes (the whole point)</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Yes</td>
      <td style="text-align: center">Limited</td>
    </tr>
    <tr>
      <td><strong>Datastore</strong></td>
      <td style="text-align: center">SQLite / Postgres</td>
      <td style="text-align: center">Embedded / Postgres</td>
      <td style="text-align: center">Own embedded DB</td>
      <td style="text-align: center">External RDBMS</td>
      <td style="text-align: center">389 DS (LDAP)</td>
    </tr>
    <tr>
      <td><strong>Footprint</strong></td>
      <td style="text-align: center">Binary + Ory services</td>
      <td style="text-align: center">Single binary</td>
      <td style="text-align: center">Single binary</td>
      <td style="text-align: center">JVM, ~1-2 GB RAM</td>
      <td style="text-align: center">Heavy, RPM only</td>
    </tr>
    <tr>
      <td><strong>License</strong></td>
      <td style="text-align: center">AGPL-3.0 + commercial</td>
      <td style="text-align: center">Apache-2.0</td>
      <td style="text-align: center">MPL-2.0</td>
      <td style="text-align: center">Apache-2.0</td>
      <td style="text-align: center">GPLv3</td>
    </tr>
  </tbody>
</table>

<p>† Organizations and SAML SSO are Forseti commercial features; the AGPL core runs as a fully working single tenant.</p>

<p><strong>Where Forseti wins.</strong> If you’ve already bet on Ory, or you’d rather run a certified OAuth2/OIDC engine than a bespoke one, nothing else gives Kratos and Hydra real screens <em>and</em> an admin console <em>and</em> first-class multi-tenant organizations. Rauthy, Kanidm and FreeIPA have no organizations model at all; only Keycloak does, and it costs you a JVM and a couple of gigs of RAM.</p>

<p><strong>Where it doesn’t.</strong> Forseti is not a full directory. It can now back Linux logins (a preview feature), but if you need an LDAP server, RADIUS, or Kerberos, that’s still Kanidm or FreeIPA territory. If you want the absolute smallest footprint with no Ory alongside, Rauthy or Kanidm are lighter to run, since a full Forseti deployment runs several services (Forseti, Kratos, Hydra, and their Postgres). And if you need the enterprise kitchen sink, Keycloak still does more, at the cost of operating Keycloak. Do take the table with a grain of salt: these projects move.</p>

<h2 id="giants-run-on-ory-so-why-forseti">Giants run on Ory, so why Forseti?</h2>

<p>Fair question, and it’s the reassuring part. Ory is what the giants scale with: OpenAI self-hosts Ory Hydra to issue tokens for ChatGPT, which is hundreds of millions of weekly users. So Forseti’s throughput ceiling isn’t Forseti; it’s Ory, the most battle-tested engine in that whole comparison table. Forseti is young, but the thing carrying your auth load underneath is not.</p>

<p>The other half is that Forseti doesn’t lock you in. Ory is the contract; Forseti is only the face. Outgrow self-hosting? Move to Ory Network (their cloud) and keep your identities. Want something Forseti doesn’t do? Build your own frontend against the exact same Kratos and Hydra APIs and swap it in. Nothing you configure here is a dead end. That makes Forseti a low-risk stepping stone: it gets you real screens today, and if it turns out to be all you ever need, it’s easily themeable, so you can make it look like your product rather than a stock login page. A stepping stone that quietly makes for a fine permanent answer.</p>

<h2 id="running-it">Running it</h2>

<p>Prebuilt binaries for x86_64 and aarch64 Linux are attached to every release, or you can pull the container from GHCR. Copy <code class="language-plaintext highlighter-rouge">config.example.toml</code>, point it at your Kratos and Hydra admin endpoints, pick SQLite or Postgres, and run it. The one runtime gotcha: the binary links against <code class="language-plaintext highlighter-rouge">libpq</code>, so a bare host needs <code class="language-plaintext highlighter-rouge">libpq5</code> (Debian/Ubuntu) or <code class="language-plaintext highlighter-rouge">libpq</code> elsewhere; the container already bundles it. The full walkthrough (deployment topology, reverse-proxy cookies and CSRF, secrets, backups) lives in the <a rel="noopener" href="https://github.com/franzos/forseti/tree/master/docs">operator guide</a>, so I don’t have to keep two copies in sync.</p>

<h2 id="where-to-get-it">Where to get it</h2>

<p>There’s a <a href="/software/forseti/">product page</a> with the highlights, and the code, releases, and docs are on GitHub at <a rel="noopener" href="https://github.com/franzos/forseti">github.com/franzos/forseti</a>. The core is AGPL-3.0; a couple of features (organizations, SAML SSO) live under a commercial gate, but the open core runs as a fully working single tenant, so you can try the whole flow before any of that matters.</p>

<p>It’s pre-release and moving, so pin a commit if you build on it. But the shape is what I was after: keep Ory’s certified engines, and stop hand-rolling the login page for the tenth time. Forseti is just the front door.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="rust" /><category term="ory" /><category term="oauth2" /><category term="oidc" /><category term="self-hosted" /><category term="identity" /><summary type="html"><![CDATA[Every self-service identity flow for Ory Kratos and Ory Hydra — login, registration, recovery, MFA, OAuth2 consent — plus an admin console, in a single server-rendered Rust binary.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Liwan: a Lightweight Matomo Replacement</title><link href="https://gofranz.com/blog/liwan-a-lightweight-matomo-replacement/" rel="alternate" type="text/html" title="Liwan: a Lightweight Matomo Replacement" /><published>2026-06-20T01:00:00+01:00</published><updated>2026-06-20T01:00:00+01:00</updated><id>https://gofranz.com/blog/liwan-a-lightweight-matomo-replacement</id><content type="html" xml:base="https://gofranz.com/blog/liwan-a-lightweight-matomo-replacement/"><![CDATA[<p>I ran Matomo for years. It does the job, but self-hosting it means a PHP app, a MariaDB instance, cron for the archiving, and the general upkeep that comes with all of that. For a handful of small sites where I just want to know what pages people read and where they came from, it was more moving parts than the question deserved.</p>

<p>So I went looking for something lighter, and found <a rel="noopener" href="https://liwan.dev">Liwan</a> by <a rel="noopener" href="https://github.com/explodingcamera">explodingcamera</a>. It’s privacy-first web analytics in a single Rust binary: no cookies, no cross-site tracking, no persistent identifiers, and the tracking snippet is one line under 1KB. I liked it enough that I forked it, added the couple of things I needed to actually migrate off Matomo, and it’s been running in Matomo’s place ever since. Huge thanks to explodingcamera for the original, none of this exists without that work.</p>

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

<p>You get a clean dashboard per project: pageviews over time, referrers, top pages, countries, browsers, the usual breakdown, all updating in real time as visitors come in. Click to enlarge.</p>

<figure style="margin:1.5rem 0">
  <a href="/assets/images/blog/liwan-dashboard.png"><img src="/assets/images/blog/liwan-dashboard.png" alt="Liwan dashboard with pageviews, referrers, and breakdowns" style="width:100%;border-radius:4px" /></a>
  <figcaption style="font-size:.85em;opacity:.7;margin-top:.4rem">The project dashboard: traffic over time, plus referrers, pages, and geo breakdowns you can filter by. Screenshot from the <a rel="noopener" href="https://github.com/explodingcamera/liwan">upstream Liwan project</a> by <a rel="noopener" href="https://github.com/explodingcamera">explodingcamera</a>.</figcaption>
</figure>

<h2 id="why-it-replaced-matomo">Why it replaced Matomo</h2>

<p>The thing that sold me is what it doesn’t need. Liwan is one self-contained binary. It does use databases (DuckDB for the analytics data, SQLite for the app state), but both are embedded and bundled into the binary, so there’s no separate server to stand up, no cache layer, no archiving cron to get wrong. You can run it on a cheap VPS, an old Mac mini, or a Raspberry Pi, and it stays out of the way. It’s written in Rust on tokio, so it’s fast and it sips resources.</p>

<p>On privacy it’s stricter than most out of the box. No cookies and no persistent identifiers means no consent banner to bolt on, and the data lives on your server, not someone else’s. Bots and crawlers get filtered by default, so the numbers are closer to real people than what a lot of tools show you.</p>

<p>None of that would matter if I couldn’t bring my history along, which is the part I built.</p>

<h2 id="the-bits-i-added">The bits I added</h2>

<p>My fork sits on top of upstream and adds the few things I needed for a real migration:</p>

<ul>
  <li><strong>Matomo import.</strong> <code class="language-plaintext highlighter-rouge">liwan import matomo</code> pulls your historical pageviews across, month by month, so you don’t start from an empty dashboard. It’s incremental and safe to re-run: a per-site watermark tracks how far it got, and a crash replays cleanly instead of duplicating rows. You map each Matomo site to a Liwan entity explicitly, it retries on rate limits, and there’s a <code class="language-plaintext highlighter-rouge">--drop-local-urls</code> flag to skip the <code class="language-plaintext highlighter-rouge">localhost</code> noise from local development. I imported everything from my old instance in one pass.</li>
  <li><strong>OIDC single sign-on.</strong> Password login still works, but with an <code class="language-plaintext highlighter-rouge">[oidc]</code> section set you get a “Sign in with SSO” button and accounts provision automatically on first login. It matches users on the provider’s subject, so an email or name change never breaks the link, and there are options to restrict who can register.</li>
  <li><strong>A couple of extra dimensions.</strong> Custom events as a first-class thing you can scope reports by, and an entity dimension to break a project down per tracked site.</li>
</ul>

<p>The Matomo import and OIDC are the two that made this a drop-in rather than a fresh start.</p>

<h2 id="not-just-for-matomo">Not just for Matomo</h2>

<p>If you’re coming from Google Analytics, the pitch is the same, only more so. You swap a third-party script that phones home for a one-line snippet pointing at your own server, and the analytics stop being someone else’s product. Liwan doesn’t try to be a marketing suite; it answers “who visited, what did they read, where did they come from” and stops there. For most sites, that’s the whole question.</p>

<p>It won’t replace Matomo for everyone. If you lean on goals, funnels, e-commerce tracking, or Matomo’s heavier reporting, those aren’t here, and that’s fine: run the real thing. For the common case of wanting private, low-maintenance numbers, Liwan is the trade I wanted.</p>

<h2 id="how-it-stacks-up">How it stacks up</h2>

<p>This isn’t a feature-count contest, and if it were, Liwan would lose it. Matomo, GA, Plausible, and the rest all do far more. The point is that for a lot of sites you don’t need most of it, and the features that actually matter to me are “can I self-host it, does it respect visitors, and how much is there to run.” On those, the picture flips:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>Self-host</th>
      <th>What you run</th>
      <th>Cookieless</th>
      <th>Open source</th>
      <th>Cost</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Liwan</strong></td>
      <td>Yes</td>
      <td>Single binary (embedded DuckDB + SQLite)</td>
      <td>Yes</td>
      <td>Yes (Apache-2.0)</td>
      <td>Free</td>
    </tr>
    <tr>
      <td>GoatCounter</td>
      <td>Yes (or free hosted)</td>
      <td>Single Go binary (SQLite or PostgreSQL)</td>
      <td>Yes</td>
      <td>Yes (EUPL-1.2)</td>
      <td>Free</td>
    </tr>
    <tr>
      <td>Umami</td>
      <td>Yes (or cloud)</td>
      <td>Node app + PostgreSQL/MySQL</td>
      <td>Yes</td>
      <td>Yes (MIT)</td>
      <td>Free self-host / paid cloud</td>
    </tr>
    <tr>
      <td>Plausible</td>
      <td>Yes (CE) or cloud</td>
      <td>Elixir app + PostgreSQL + ClickHouse</td>
      <td>Yes</td>
      <td>Yes (AGPL-3.0)</td>
      <td>Free self-host / paid cloud</td>
    </tr>
    <tr>
      <td>Matomo</td>
      <td>Yes or cloud</td>
      <td>PHP + MySQL/MariaDB</td>
      <td>Optional (configurable)</td>
      <td>Yes (GPL-3.0)</td>
      <td>Free self-host / paid cloud</td>
    </tr>
    <tr>
      <td>Google Analytics</td>
      <td>No</td>
      <td>Nothing, it’s their servers</td>
      <td>No (cookies + consent)</td>
      <td>No</td>
      <td>“Free” (you’re the product)</td>
    </tr>
  </tbody>
</table>

<p>The lightweight, self-host-friendly end is more crowded than it used to be, which is a good thing. What pulled me to Liwan specifically was three things: it’s written in Rust, it has proper multi-site support, and the dashboard is genuinely nice to look at. That combination is rarer than you’d think.</p>

<h2 id="running-it">Running it</h2>

<p>It’s a single binary (or a Docker image). It reads one TOML config, looks for <code class="language-plaintext highlighter-rouge">./liwan.config.toml</code> or <code class="language-plaintext highlighter-rouge">~/.config/liwan/config.toml</code>, and any value can be overridden with a <code class="language-plaintext highlighter-rouge">LIWAN_*</code> environment variable, which is how you keep secrets out of the file. Point your reverse proxy at it, drop the snippet on your pages, and you’re collecting.</p>

<p>The full setup, the OIDC configuration, and the Matomo import walkthrough live in the <a rel="noopener" href="https://github.com/franzos/liwan">README</a>, so I don’t have to keep two copies in sync.</p>

<h2 id="where-to-get-it">Where to get it</h2>

<p>Upstream is at <a rel="noopener" href="https://liwan.dev">liwan.dev</a> and <a rel="noopener" href="https://github.com/explodingcamera/liwan">github.com/explodingcamera/liwan</a>. My fork, with the Matomo import and OIDC, is at <a rel="noopener" href="https://github.com/franzos/liwan">github.com/franzos/liwan</a>. There’s a live demo on <a rel="noopener" href="https://demo.liwan.dev/p/liwan.dev">demo.liwan.dev</a> if you want to click around before installing anything.</p>

<p>That’s the shape I was after: a tool that answers the analytics question, keeps the data on my server, and is boring to run. One binary, one config file, my history carried over, and no database server to keep alive.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="rust" /><category term="analytics" /><category term="self-hosted" /><category term="privacy" /><category term="matomo" /><summary type="html"><![CDATA[A privacy-first, self-hosted analytics tool that dropped in where my Matomo instance used to be. One Rust binary, no database to babysit, and I imported all the old data.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">TKU: Claude Code on Steroids</title><link href="https://gofranz.com/blog/tku-claude-code-on-steroids/" rel="alternate" type="text/html" title="TKU: Claude Code on Steroids" /><published>2026-06-13T01:00:00+01:00</published><updated>2026-06-13T01:00:00+01:00</updated><id>https://gofranz.com/blog/tku-claude-code-on-steroids</id><content type="html" xml:base="https://gofranz.com/blog/tku-claude-code-on-steroids/"><![CDATA[<p>I run Claude Code all day. Company subscription for work, my own subscription for the side projects and the tinkering. Two problems came out of that pretty quickly: I had no real sense of what I was actually burning, and switching between the two accounts meant fiddling with credential files by hand. So I built <a rel="noopener" href="https://github.com/franzos/tku">TKU</a>, a small Rust CLI that answers both.</p>

<p>It reads the session files Claude Code already writes to disk, fetches live pricing, and shows you aggregated reports. No proxy, no wrapper around the tool, nothing to sign up for. Point it at a machine that runs Claude Code and it just works. It’ll also read Codex, Gemini CLI, Amp, OpenCode, and a handful of others if you use them, but Claude Code is what I built it for.</p>

<h2 id="the-numbers-i-actually-wanted">The numbers I actually wanted</h2>

<p>The default <code class="language-plaintext highlighter-rouge">tku</code> gives you today’s usage. From there it fans out:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tku            <span class="c"># daily usage</span>
tku monthly    <span class="c"># monthly aggregation</span>
tku model      <span class="c"># per-model breakdown</span>
tku session    <span class="c"># per-session</span>
tku plot 1w    <span class="c"># inline bar chart of the last week</span>
</code></pre></div></div>

<p>You get token counts split the way they actually bill (input, output, cache write, cache read), per-model cost, and breakdowns by project and tool. The cache-read line alone is worth seeing: it’s usually the bulk of the tokens and almost none of the cost, and once you’ve looked at it a few times you stop worrying about context size the way you used to.</p>

<p>Two commands go a bit further than “what did I spend”:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">tku sub</code></strong> pulls your Claude Max/Pro utilization straight from Anthropic’s OAuth API, so you see how much of the plan you’ve actually used this cycle, not just dollar-equivalents. With <code class="language-plaintext highlighter-rouge">--plan</code> it compares your usage against upgrading or downgrading and tells you which plan you should be on. For me that turned a vague “am I getting my money’s worth” into a straight answer.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">tku model-burn</code></strong> measures intensity rather than totals: tokens per minute, cost per active hour, cost per calendar day, with idle-time clamping so a lunch break doesn’t skew the rate. It’s the difference between “I spent X this week” and “this is how hard I lean on the tool when I’m actually working.”</li>
</ul>

<p>There’s also a <code class="language-plaintext highlighter-rouge">watch</code> command that live-updates as the session files change (inotify on Linux, FSEvents on macOS), and a <code class="language-plaintext highlighter-rouge">bar</code> command that emits JSON for waybar, i3bar, or polybar with warn/critical thresholds. I keep the running cost in my status bar now, which is either healthy or a bad idea depending on your temperament.</p>

<h2 id="switching-work-and-personal-accounts">Switching work and personal accounts</h2>

<p>This is the part I use most. Claude Code stores its login in <code class="language-plaintext highlighter-rouge">~/.claude/.credentials.json</code> and stashes account details in <code class="language-plaintext highlighter-rouge">~/.claude.json</code>. Swapping between two subscriptions means swapping both, and doing it by hand is exactly the kind of fiddly, easy-to-get-wrong thing you don’t want in the middle of your day.</p>

<p>TKU’s accounts subsystem handles it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tku account            <span class="c"># list saved accounts</span>
tku account swap work  <span class="c"># switch to the work subscription</span>
tku account swap personal
</code></pre></div></div>

<p>Each account keeps its own credentials, but shared settings (your skills, your <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>) stay put across the swap, so your setup doesn’t change just because the login did. The first time you run it, your current login gets registered as <code class="language-plaintext highlighter-rouge">default</code> automatically, so you’re not starting from an empty slate. There are safeguards in place: it won’t clobber an unsaved login, and it won’t let you delete the account you’re currently on.</p>

<p>The nice payoff is attribution. Every swap is timestamped, so reports can be filtered to a single account’s windows:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tku monthly <span class="nt">--account</span> work
</code></pre></div></div>

<p>Now “what did the company subscription cost this month” and “what did I spend on my own stuff” are two different questions with two different answers, instead of one blurry total.</p>

<h2 id="running-it">Running it</h2>

<p>It’s a single binary, available via Homebrew, <code class="language-plaintext highlighter-rouge">.deb</code>, <code class="language-plaintext highlighter-rouge">.rpm</code>, Guix, or Cargo. The <a rel="noopener" href="https://github.com/franzos/tku#install">install section on GitHub</a> has the command for each.</p>

<p>Config is optional and lives in <code class="language-plaintext highlighter-rouge">~/.config/tku/config.toml</code> if you want it: pick your pricing source (litellm, openrouter, or llmprices) and your currency (any ISO 4217 code, mine’s EUR).</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="py">pricing_source</span> <span class="p">=</span> <span class="s">"litellm"</span>
<span class="py">currency</span> <span class="p">=</span> <span class="s">"EUR"</span>
</code></pre></div></div>

<p>Cold start does a full scan and cache build (around 20 to 30 seconds depending on the backend), and every run after that is warm at roughly half a second. There are two cache backends if you care: bitcode by default, or SQLite if you’d rather.</p>

<h2 id="what-it-isnt">What it isn’t</h2>

<p>TKU reads what the tools write locally, so its numbers are as good as those session files, and pricing is a best-effort fetch, not an invoice. It’s a dashboard for your own consumption, not accounting. But for knowing what I’m burning, whether I’m on the right plan, and keeping my two subscriptions cleanly separated, it’s become part of how I run Claude Code every day.</p>

<p>It’s <a rel="noopener" href="https://github.com/franzos/tku">on GitHub</a> under GPL-3.0. If you live in Claude Code the way I do, it’s worth the two minutes to install.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="tku" /><category term="rust" /><category term="claude-code" /><category term="cli" /><category term="tooling" /><summary type="html"><![CDATA[A single Rust binary that tracks token usage and cost across every AI coding tool, and lets me swap between my work and personal Claude subscriptions with one command.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Keycloak Multi-Tenancy: Realms vs Organizations</title><link href="https://gofranz.com/blog/keycloak-multi-tenancy-realms-vs-organizations/" rel="alternate" type="text/html" title="Keycloak Multi-Tenancy: Realms vs Organizations" /><published>2026-05-14T13:00:00+01:00</published><updated>2026-05-14T13:00:00+01:00</updated><id>https://gofranz.com/blog/keycloak-multi-tenancy-realms-vs-organizations</id><content type="html" xml:base="https://gofranz.com/blog/keycloak-multi-tenancy-realms-vs-organizations/"><![CDATA[<p>I’d heard repeatedly that “Keycloak gets weird with many realms,” but I never had concrete numbers to back it up. If you’re building multi-tenant SaaS on Keycloak, you’ve got two options: the classic <em>realm-per-tenant</em> model, or the newer <em>organizations</em> feature (KC 26+) that keeps every tenant in a single realm. Conventional wisdom says realms-per-tenant doesn’t scale, but by how much, and where does it actually fall over?</p>

<p>So I wrote a small benchmark to find out. A docker-compose stack, a REST seeder, three k6 workloads, and a sweep driver that walks tenant counts from 1 to 1500 in both modes. Everything runs on a single laptop (Ryzen 7640U, 60 GB RAM, Keycloak 26.0 with Postgres). The absolute numbers will shift on other hardware; the <em>shape</em> of the curves won’t.</p>

<h2 id="the-headline-the-admin-api-collapses">The headline: the admin API collapses</h2>

<p>The admin API (listing realms, fetching realm metadata, listing users) is what the admin console hits on every page load. In realms mode it degrades catastrophically: throughput drops <strong>four orders of magnitude</strong> as you add tenants, and p95 latency goes from 2 ms at one tenant to 16.6 s at 500. By 1000 realms, requests just time out.</p>

<p>Organizations mode actually starts <em>slower</em> at N=1 (fewer optimisations in the per-org code path) but stays essentially flat all the way through 1500 tenants.</p>

<p><img src="/assets/images/blog/keycloak-benchmark_admin-api-throughput.png" alt="Admin API throughput: realms vs organizations" /></p>

<h2 id="provisioning-is-brutal-too">Provisioning is brutal too</h2>

<p>Same story for tenant onboarding. Seeding each tenant through the standard admin REST API (two clients, a handful of roles and groups, ten users), at N=1000 realms mode took <strong>59 minutes</strong>. Orgs mode took <strong>84 seconds</strong>, a 42× gap, and the realms curve is super-linear.</p>

<p><img src="/assets/images/blog/keycloak-benchmark_seed-time.png" alt="Seed time: realms vs organizations" /></p>

<h2 id="and-then-it-ran-itself-out-of-memory">And then it ran itself out of memory</h2>

<p>This was the one I didn’t expect. Somewhere around 1200 realms, during seeding, Keycloak’s <code class="language-plaintext highlighter-rouge">ClearExpiredUserSessions</code> scheduled task started iterating every realm on every tick, and each call rehydrated the realm cache. Within minutes, process RSS went from ~1 GB to <strong>43 GB</strong> (72 % of the box) and CPU pegged at 600 %. I killed it. Orgs mode at 1500 tenants sits at the same ~880 MB it used at one.</p>

<p><img src="/assets/images/blog/keycloak-benchmark_rss.png" alt="RSS: realms vs organizations" /></p>

<h2 id="what-i-take-from-it">What I take from it</h2>

<p>For realm-per-tenant multi-tenancy on a single node, Keycloak isn’t viable much past ~500 tenants, and the failure mode beyond that is a hard one (OOM, not just slow). Organizations mode holds up. The user-facing bits (token issuance, JWKS) degrade gracefully in <em>both</em> modes, so this is really an admin-plane and provisioning problem, not a login one.</p>

<p>If you need true realm-level isolation, you’re not wrong to want it, you just can’t do it single-node at scale. Everyone else: reach for organizations.</p>

<p>The full write-up (all the workloads, cold-vs-warm numbers, sizing suggestions, and the caveats: single-node only, plain HTTP, default Postgres) plus the reproducible setup is in the repo. There’s even a Claude Code skill that runs the whole sweep for you.</p>

<p><strong><a rel="noopener" href="https://github.com/franzos/keycloak-benchmark">github.com/franzos/keycloak-benchmark</a></strong></p>

<p>Do take the absolute throughput figures with a grain of salt, they’re from one laptop, one KC release. The realms-vs-orgs delta is the part that travels.</p>]]></content><author><name>Franz Geffke</name></author><category term="[&quot;Tools&quot;]" /><category term="keycloak" /><category term="multi-tenancy" /><category term="performance" /><category term="benchmark" /><category term="identity" /><summary type="html"><![CDATA[I'd heard Keycloak gets weird with many realms. So I benchmarked it. It does.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://gofranz.com/assets/images/og-default.png" /><media:content medium="image" url="https://gofranz.com/assets/images/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>