<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-FileCopyrightText: 2026 Ahmed Taha Bouallegui -->
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!-- Generated by scripts/build-feed.mjs from posts/*.html. Edit the posts, not this file. -->
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Ahmed Bouallegui: field notes</title>
    <link>https://bouallegui.pages.dev/blog.html</link>
    <description>Notes from the lab and from my own machines: a NixOS config shared by three machines, wireless detection over nl80211, self-hosted services, and running models locally.</description>
    <language>en</language>
    <copyright>Copyright 2026 Ahmed Taha Bouallegui, GPL-3.0-or-later</copyright>
    <lastBuildDate>Sun, 04 Oct 2026 12:00:00 GMT</lastBuildDate>
    <generator>scripts/build-feed.mjs</generator>
    <atom:link href="https://bouallegui.pages.dev/feed.xml" rel="self" type="application/rss+xml"/>
    <image>
      <url>https://bouallegui.pages.dev/images/feed.png</url>
      <title>Ahmed Bouallegui: field notes</title>
      <link>https://bouallegui.pages.dev/blog.html</link>
      <width>144</width>
      <height>144</height>
    </image>
    <item>
      <title>What a harness is, learned the slow way</title>
      <link>https://bouallegui.pages.dev/posts/what-a-harness-is-learned-the-slow-way.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/what-a-harness-is-learned-the-slow-way.html</guid>
      <pubDate>Sun, 04 Oct 2026 12:00:00 GMT</pubDate>
      <description>pi, OpenCode and DeepSeek Harness, all pointed at the same OpenAI-compatible endpoint on my own hardware. What each one taught me about the layer between me and the model.</description>
      <content:encoded><![CDATA[<figure class="container mt-10 px-5 md:px-8">
        <img src="https://bouallegui.pages.dev/images/projects/harnesses.svg" alt="Diagram: three harnesses, pi, opencode and dsh, each with an arrow labelled openai-completions pointing into a dashed boundary marked my own hardware only. Inside are three endpoints: amal on port 8901 with ik_llama.cpp, gimel on port 8080 with llama.cpp, and gimel2 on port 8080 with Strata. Below the boundary an arrow to someone else's inference endpoint with a slash through it." width="640" height="360" loading="lazy" decoding="async" class="w-full max-w-3xl rounded-xl shadow-soft mx-auto" data-lightbox="img">
        <figcaption class="mt-2 text-center text-sm text-slate-500">Three harnesses, one decision. The dashed line is the interesting part.</figcaption>
      </figure>

      <div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">I run my own model, and <a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="font-semibold text-link hover:text-ocean-dark">part 6 of this series</a> covers that side for the laptop: llama.cpp, a quantized GGUF file, an OpenAI-compatible API on a port. The two boxes on my tailnet run a different engine for a much larger model. This post is about the layer above both of them, the program you actually type into. That layer is the one I understood last, and it is the one I ended up configuring hardest.</p>
        <p class="leading-relaxed">Three harnesses went through the machine. Each left a config file and a directory of session logs, so everything below is something I can point at on disk rather than remember. The hostnames are my own names for my machines, and the config excerpts use them too.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What a harness decides</h2>
        <p class="leading-relaxed">For a while I treated the harness as a product: you install it, it has a model behind it, you talk to it. Under that model of it, changing harnesses means changing what the AI can do, which is mostly false.</p>
        <p class="leading-relaxed">A harness is the thing that decides what goes into the context window, which tools exist at all, what happens when a tool call fails, how much of your machine the model may touch, and whether the conversation is summarized before it hits the limit. The weights are the part you usually do not control. Everything in that list is the part you do.</p>
        <p class="leading-relaxed">The first place that landed for me was a fourteen line file. Here is the entire model configuration I used with Pi:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">{
  "providers": {
    "llama-server": {
      "baseUrl": "http://127.0.0.1:8900/v1",
      "api": "openai-completions",
      "apiKey": "local",
      "models": [
        {
          "id": "Ornith",
          "contextWindow": 131072,
          "maxTokens": 4096
        }
      ]
    }
  }
}</code></pre>
        <p class="leading-relaxed">Two of those lines are hardware decisions wearing model clothes. <code class="font-mono text-sm text-ocean-dark">contextWindow</code> and <code class="font-mono text-sm text-ocean-dark">maxTokens</code> are a budget: how much prompt the model may hold, and how much room to leave for the answer. On a ThinkPad T480 serving a 35B model at Q4_K_M from CPU kernels, that budget is set by the machine, not by the model card, and a harness that never asks you for it is guessing on your behalf.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Three endpoints, three config files</h2>
        <p class="leading-relaxed">Before I had picked a harness I had already put the model behind an HTTP URL. Three endpoints, all mine or on my tailnet, two engines behind them, all speaking the same OpenAI-compatible API:</p>
        <ul class="space-y-2 pl-5 list-disc marker:text-link">
          <li class="leading-relaxed"><code class="font-mono text-sm text-ocean-dark">amal</code>, the ThinkPad I use every day, serving <code class="font-mono text-sm text-ocean-dark">Ornith-1.5-35B-Uncensored-Q4_K_M.gguf</code> on port 8901 through the <a href="https://github.com/ikawrakow/ik_llama.cpp" class="font-semibold text-link hover:text-ocean-dark">ik_llama.cpp</a> fork, built with all CPU variant kernels. Port 8901 rather than 8900 because SearXNG owns 8900 on that machine.</li>
          <li class="leading-relaxed"><code class="font-mono text-sm text-ocean-dark">gimel</code> on port 8080, serving <code class="font-mono text-sm text-ocean-dark">peculiar-ragdoll/Cyber-Tiel-Coder-35B-A3B-GGUF-MTP</code> over llama.cpp.</li>
          <li class="leading-relaxed"><code class="font-mono text-sm text-ocean-dark">gimel2</code> on port 8080, running <a href="https://github.com/Niko1221/Strata" class="font-semibold text-link hover:text-ocean-dark">Strata</a>, an MIT licensed inference engine built around one model, Qwen3.8-Flash-Next. The quant my current configuration names is <code class="font-mono text-sm text-ocean-dark">IQ3_S</code> from the ISTA-DASLab GSQ-RCO files; an earlier one named Unsloth's <code class="font-mono text-sm text-ocean-dark">UD-IQ4_XS</code>.</li>
        </ul>
        <p class="leading-relaxed">Once the model is a URL, a harness is a client, and a client is a config file. Here is the same decision in OpenCode:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">"gimel2": {
  "name": "gimel2",
  "npm": "@ai-sdk/openai-compatible",
  "options": {
    "baseURL": "http://gimel2.tail5481a4.ts.net:8080/v1"
  },
  "models": {
    "qwen3.8-flash-next": {
      "name": "Qwen3.8-Flash-Next",
      "modalities": {
        "input": ["text", "image"],
        "output": ["text"]
      }
    }
  }
}</code></pre>
        <p class="leading-relaxed">And the same decision again in the flake, as a patch the harness reads on activation:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">{
  id = "llm-pi-ai";
  name = "@deepseek-ai/dsh-llm-pi-ai";
  config = {
    providers = {
      gimel2 = {
        displayName = "gimel2";
        apiKeyEnv = "GIMEL2_API_KEY";
        api = "openai-completions";
        baseURL = "http://gimel2.tail5481a4.ts.net:8080/v1";</code></pre>
        <p class="leading-relaxed">Three syntaxes, one decision. That is why changing harnesses cost me an afternoon each time instead of a rebuild of how I work.</p>

        <p class="leading-relaxed">Strata uses parts of llama.cpp and ggml, so the engine on gimel2 and the engine on the laptop share ancestry, and both end at the same kind of URL. The hardware is uneven, and the API is what makes it one thing to configure against.</p>
        <p class="leading-relaxed">Which engine sits behind each of those URLs, which quantization it is served at, and what that costs in tokens per second on the three machines involved is a post of its own: <a href="https://bouallegui.pages.dev/posts/getting-a-125b-model-onto-a-12gb-card.html" class="font-semibold text-link hover:text-ocean-dark">Getting a 125B model onto a 12 GB card</a>. From the harness side the endpoints differ in speed, not in protocol, and that is the only part that matters there.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Pi: one provider block and two packages</h2>
        <p class="leading-relaxed"><a href="https://pi.dev/" class="font-semibold text-link hover:text-ocean-dark">Pi</a> (<a href="https://github.com/earendil-works/pi" class="font-semibold text-link hover:text-ocean-dark">source</a>, MIT) is deliberately minimal. No sub-agents, no plan mode, no permission popups, no background bash in the core; those are things you build or install as packages. What it gives you instead is control of the context: a small system prompt, <code class="font-mono text-sm text-ocean-dark">AGENTS.md</code> loaded from its own config directory and the directory tree, and sessions stored as a tree in one file so you can branch back to any earlier point.</p>
        <p class="leading-relaxed">My setup was the provider block above plus two packages, <code class="font-mono text-sm text-ocean-dark">pi-web-access</code> and <code class="font-mono text-sm text-ocean-dark">pi-mcp-adapter</code>, with telemetry switched off. Nothing else.</p>
        <p class="leading-relaxed">What it taught me came out of its session logs, which are plain JSONL. The largest session in the directory is the first attempt at this website. It ran 297 messages and 145 tool calls, and the split is the number worth knowing: 75 <code class="font-mono text-sm text-ocean-dark">bash</code>, 32 <code class="font-mono text-sm text-ocean-dark">edit</code>, 26 <code class="font-mono text-sm text-ocean-dark">write</code>, 12 <code class="font-mono text-sm text-ocean-dark">read</code>. Half the session was the model checking what the shell would actually do. Reading that ratio is the first harness skill I developed, and it says more about a run than the transcript does.</p>
        <p class="leading-relaxed">Pi's lack of opinions is honest, but it means everything above the primitives is yours to build, and building it is exactly the work a small local model is worst at.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">OpenCode: providers, plugins and compaction</h2>
        <p class="leading-relaxed">I had downloaded <a href="https://opencode.ai/" class="font-semibold text-link hover:text-ocean-dark">OpenCode</a> once before and put it down again without getting anywhere with it. The time I actually used it is the time that produced the config below, and it is the harness where I spent the most tokens: 16,028,123 input and 2,659,633 output on the ledger, for a recorded cost of $0.04, because nearly all of it went to my own endpoints.</p>
        <p class="leading-relaxed">OpenCode ships with the machinery Pi leaves out: sub-agents, plan mode, a permission system, LSP integration. My config points it at three local providers, adds three MCP servers (one of them <code class="font-mono text-sm text-ocean-dark">nix run nixpkgs#mcp-nixos</code>, for NixOS option lookups), sets <code class="font-mono text-sm text-ocean-dark">"bash": "ask"</code> and <code class="font-mono text-sm text-ocean-dark">"edit": "allow"</code>, and installs six plugins:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">"plugin": [
  "oh-my-openagent",
  "opencode-skillful",
  "opencode-supermemory",
  "opencode-notifier",
  "opencode-scheduler",
  "opencode-dynamic-context-pruning"
],
"compaction": {
  "auto": true,
  "prune": false,
  "reserved": 10000
}</code></pre>
        <p class="leading-relaxed">The last two of those are the local-model adaptations, and they are the part of the config I would defend. A context pruning plugin and a compaction step that holds 10,000 tokens back are not there to make the agent clever. They are there because with a local model, context is not a bill, it is minutes of my own hardware, and a session that drags its whole history is slow in a way that stops you working.</p>
        <p class="leading-relaxed">The plan plugin keeps a work ledger at <code class="font-mono text-sm text-ocean-dark">~/.omo/boulder.json</code> with named plans, an agent name and elapsed time. The entry for this website records 95 minutes for the plan that produced the first commit. The same ledger and the session database show the shape of that run: a parent session, then short-lived children doing the reading, one of which took 905,542 input tokens to produce 11,678 output tokens of summary. Sub-agents multiply context faster than they multiply useful work, and when the model is local the price arrives as waiting rather than as money.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">DeepSeek Harness: installed by the flake</h2>
        <p class="leading-relaxed"><a href="https://github.com/deepseek-ai/deepseek-harness" class="font-semibold text-link hover:text-ocean-dark">DeepSeek Harness</a> is what I run now, and it won on the one thing the other two did not offer: it installs as a Nix flake input, so the harness is part of the system I rebuild rather than something I installed once with npm.</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">programs.dsh = {
  enable = true;
  profiles.web = {
    bundles = [
      pkgs.dsh.bundles.base
      pkgs.dsh.bundles.web-app
      pkgs.dsh.bundles.web-ui
    ];</code></pre>
        <p class="leading-relaxed">The provider is the patch quoted above, so every activation rewrites it, and the API key it reads is the string <code class="font-mono text-sm text-ocean-dark">"x"</code>, set in <code class="font-mono text-sm text-ocean-dark">environment.sessionVariables</code>. The endpoint does not authenticate, because reachability is the tailnet, and Strata's own documentation says any key and any model name will be accepted. I would rather declare a dummy than keep a secret in a file that is not secret.</p>
        <p class="leading-relaxed">The two timeouts in <code class="font-mono text-sm text-ocean-dark">~/.dsh/settings.yaml</code> are both 900,000 ms. Fifteen minutes of patience is arithmetic rather than caution: gimel2 reads a prompt at 997 tokens per second, so a 30,000 token first message costs about half a minute before the first token of the answer appears, and an agent run stacks several of those. It also answers one request at a time unless you turn on batching. A harness with a shorter default will kill work that was going fine.</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">agent-default-model:
  provider: gimel2
  model: qwen3.8-flash-next-iq3_s</code></pre>
        <p class="leading-relaxed">The practical difference from the other two is recovery. A fresh machine, or a wiped home directory, gets the model endpoints, the timeouts and the default model back from <code class="font-mono text-sm text-ocean-dark">nixos-rebuild switch</code>. There is nothing to re-download and no key to go looking for.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What stays on my machines</h2>
        <p class="leading-relaxed">Worth stating precisely, because "local AI" gets used loosely:</p>
        <ul class="space-y-2 pl-5 list-disc marker:text-link">
          <li class="leading-relaxed">The quantized model files sit on hardware I own, and the engine on the laptop is built from source by a Nix expression I wrote.</li>
          <li class="leading-relaxed">The endpoints are tailnet hostnames. Nothing is listening on the WAN, and the authentication is the WireGuard interface rather than a token.</li>
          <li class="leading-relaxed">Prompts, tool output and session history stay on my machines. The session logs are the reason this post exists, and no third party has a copy of them.</li>
          <li class="leading-relaxed">The harness configuration is in the same git repository as the rest of the system config.</li>
        </ul>
        <p class="leading-relaxed">One boundary I would rather state than gloss over: the OpenCode config also carried the free hosted models that provider ships with, and I used them for tasks the laptop could not carry. Those sessions are separable in the same database, and the rule I would keep is to know exactly which ones they were rather than to pretend the setup is all local.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">This repository, three harnesses</h2>
        <p class="leading-relaxed">This repository is the cleanest test case, because all three touched it or touched a version of it. Pi wrote the first attempt, a Next.js app. OpenCode rebuilt the idea, then dropped the framework, leaving a directory with <code class="font-mono text-sm text-ocean-dark">index.html</code>, <code class="font-mono text-sm text-ocean-dark">style.css</code> and <code class="font-mono text-sm text-ocean-dark">app.js</code>, and then ran the plan that produced this repo. DeepSeek Harness has been writing it since. The repo does not care which program made a commit, and that is the point: the parts that made it swappable were decided before any harness was chosen.</p>

        <div class="overflow-x-auto">
          <table class="w-full text-sm">
            <thead>
              <tr class="text-left border-b border-slate-200"><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">Harness</th><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">How the model is wired in</th><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">What I tuned for a local model</th></tr>
            </thead>
            <tbody class="text-slate-600">
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">Pi</td><td class="py-2 pr-4">a 14 line <code class="font-mono text-xs">models.json</code>, one <code class="font-mono text-xs">openai-completions</code> provider</td><td class="py-2 pr-4">context window 131072, completion budget 4096</td></tr>
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">OpenCode</td><td class="py-2 pr-4">provider blocks on <code class="font-mono text-xs">@ai-sdk/openai-compatible</code>, three hosts</td><td class="py-2 pr-4">compaction holding 10000 tokens back, a pruning plugin, bash set to ask</td></tr>
              <tr><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">DeepSeek Harness</td><td class="py-2 pr-4">a patch in the flake, one provider, dummy key</td><td class="py-2 pr-4">900000 ms timeouts, an IQ3 quant as the default model</td></tr>
            </tbody>
          </table>
        </div>

        <div class="rounded-card border border-ocean/20 bg-ocean/5 p-5 text-sm">
          <p class="font-semibold text-ocean-dark mb-1">If you are building the same workflow</p>
          <ul class="space-y-1 text-slate-600">
            <li>• Put the model behind an OpenAI-compatible URL before you choose a harness. It is the decision that made every later change cheap.</li>
            <li>• Set the context budget yourself. A harness default is written for a hosted model, and on a laptop it is usually wrong in both directions.</li>
            <li>• Keep <code class="font-mono text-sm text-ocean-dark">AGENTS.md</code> in the repository rather than in the tool's config. It is the one artifact that survived all three harnesses unchanged.</li>
            <li>• Read the tool-call mix in the session log before blaming the model. 75 of 145 calls being <code class="font-mono text-sm text-ocean-dark">bash</code> is a fact about the task.</li>
            <li>• Put the harness configuration in the same flake as the system, so a rebuilt machine gets the workflow back on its own.</li>
          </ul>
        </div>

        <h2 class="mt-2 text-2xl font-display font-semibold text-ocean-dark">Further reading</h2>
        <ul class="space-y-2">
          <li><a href="https://pi.dev/" class="font-semibold text-link hover:text-ocean-dark">Pi</a> and its <a href="https://github.com/earendil-works/pi" class="font-semibold text-link hover:text-ocean-dark">source</a>: the minimal harness, and the page listing what it deliberately does not ship.</li>
          <li><a href="https://opencode.ai/" class="font-semibold text-link hover:text-ocean-dark">OpenCode</a>: the config schema my provider, plugin and compaction blocks are written against.</li>
          <li><a href="https://github.com/deepseek-ai/deepseek-harness" class="font-semibold text-link hover:text-ocean-dark">DeepSeek Harness</a>: the plugin architecture and the NixOS module I install it through.</li>
          <li><a href="https://github.com/Niko1221/Strata" class="font-semibold text-link hover:text-ocean-dark">Strata</a>: the inference engine on the two tailnet boxes, and the page that explains how a 125B mixture of experts is split across GPU, RAM and SSD.</li>
          <li><a href="https://github.com/ikawrakow/ik_llama.cpp" class="font-semibold text-link hover:text-ocean-dark">ik_llama.cpp</a>: the fork behind the laptop endpoint.</li>
        </ul>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="font-semibold text-link hover:text-ocean-dark">Running the model myself</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/one-flake-three-machines.html" class="font-semibold text-link hover:text-ocean-dark">One flake, three machines that barely resemble each other</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">The numbers come from the session directories and databases each harness keeps, plus the flake module that configures the current one, all on the machine they happened on.</p>
        </div>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>Getting a 125B model onto a 12 GB card</title>
      <link>https://bouallegui.pages.dev/posts/getting-a-125b-model-onto-a-12gb-card.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/getting-a-125b-model-onto-a-12gb-card.html</guid>
      <pubDate>Sun, 27 Sep 2026 12:00:00 GMT</pubDate>
      <description>Which engine, which quantization and which context on three specific machines, with the tokens per second each one gives. Plus the Robotech formation I wrote from the same material.</description>
      <content:encoded><![CDATA[<figure class="container mt-10 px-5 md:px-8">
        <img src="https://bouallegui.pages.dev/images/projects/moe-split.svg" alt="Diagram: a grid of experts on the left, with a few highlighted as the ones a single token uses. On the right, three stacked boxes: an RTX 3060 with 12 GB holding the few thousand hottest experts, 64 GB of RAM holding all of them, and an NVMe SSD holding a 29 GB lookup table. A note says the processor works on the rest at the same time, and a draft model guesses the next words while the big model checks them in one pass." width="640" height="360" loading="lazy" decoding="async" class="w-full max-w-3xl rounded-xl shadow-soft mx-auto" data-lightbox="img">
        <figcaption class="mt-2 text-center text-sm text-slate-500">The model is bigger than the card. The question is where the rest of it lives.</figcaption>
      </figure>

      <div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed"><a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="font-semibold text-link hover:text-ocean-dark">Part 6 of the NixOS series</a> covers how the model is wired into my system: the build, the service, the OpenAI-compatible API. It does not cover the part I actually spend time on, which is choosing the engine, the quantization and the context length for a specific piece of hardware. That choice is where the speed comes from, and it is the part I can now show you numbers for.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Weights plus KV cache</h2>
        <p class="leading-relaxed">One line decides whether a model runs at all:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">weights (parameters x bits per weight) + KV cache (context) &lt;= RAM / VRAM</code></pre>
        <p class="leading-relaxed">Quantization buys you the first term. A model ships as 16 bit numbers; storing them at 4 or 5 bits divides the memory by three or four with little visible loss. The scheme I recommend to anyone, and the one both 35B models run, is <code class="font-mono text-sm text-ocean-dark">Q4_K_M</code>, around 4.8 bits per weight: most of what <code class="font-mono text-sm text-ocean-dark">Q8_0</code> gives at half the size. The one exception is Qwen3.8-Flash-Next at <code class="font-mono text-sm text-ocean-dark">IQ3_S</code>, which is a much larger model whose experts can afford a tighter quantization. Below <code class="font-mono text-sm text-ocean-dark">Q3_K_M</code> quality falls off fast enough that the saved gigabytes usually cost more than they save.</p>
        <p class="leading-relaxed">The KV cache is the second term and the one people forget. When the model reads a token it keeps that token's keys and values in memory, because recomputing them at every step would mean re-reading the whole conversation each time. That store is the cache, and it grows with the context, which is why the context window is a physical limit rather than a setting: attention lets each token interact with every other one, so doubling the context roughly quadruples that work. Mine is stored at <code class="font-mono text-sm text-ocean-dark">q8_0</code>, eight bits per value instead of the sixteen a full-precision cache uses, which halves the memory the same window costs. Quantizing the cache is a separate decision from quantizing the weights and worth making on purpose: the weights decide what the model knows, the cache decides how much of the conversation it can hold.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Prefill and decode</h2>
        <p class="leading-relaxed">Prefilling processes the whole prompt in parallel, which is what a graphics card is good at. Decoding emits one token at a time and is limited by memory bandwidth. On my machines the ratio between the two is around ten to one, so a harness that sends long prompts is paying a different cost than a chat that shows a short answer. This is the reason an agent loop feels nothing like a chat window pointed at the same model.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What each box runs</h2>
        <p class="leading-relaxed">Both boxes are an i9-13900 with an RTX 3060 holding 12 GB of VRAM and 64 GB of RAM. The laptop is a ThinkPad T480 with an i7-8650U and 32 GB. These are readings taken while working, not a benchmark run:</p>
        <div class="overflow-x-auto">
          <table class="w-full text-sm">
            <thead>
              <tr class="text-left border-b border-slate-200"><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">Box</th><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">Engine</th><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">Model</th><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">Decode</th><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">Prefill</th></tr>
            </thead>
            <tbody class="text-slate-600">
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">amal</td><td class="py-2 pr-4">ik_llama.cpp, CPU only</td><td class="py-2 pr-4">Ornith 1.5 35B Q4_K_M, 256k context</td><td class="py-2 pr-4 whitespace-nowrap">10 tok/s</td><td class="py-2 pr-4 whitespace-nowrap">not measured</td></tr>
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">gimel</td><td class="py-2 pr-4">llama.cpp</td><td class="py-2 pr-4">Cyber-Tiel-Coder 35B A3B with MTP, Q4_K_M, 1M context</td><td class="py-2 pr-4 whitespace-nowrap">20 tok/s</td><td class="py-2 pr-4 whitespace-nowrap">154 tok/s</td></tr>
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">gimel2, before</td><td class="py-2 pr-4">llama.cpp</td><td class="py-2 pr-4">Qwen3.8-Flash-Next, 256k context</td><td class="py-2 pr-4 whitespace-nowrap">12 down to 6 tok/s</td><td class="py-2 pr-4 whitespace-nowrap">55 tok/s</td></tr>
              <tr><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">gimel2, now</td><td class="py-2 pr-4">Strata</td><td class="py-2 pr-4">Qwen3.8-Flash-Next IQ3_S, 256k context</td><td class="py-2 pr-4 whitespace-nowrap">49 down to 45 tok/s</td><td class="py-2 pr-4 whitespace-nowrap">997 tok/s</td></tr>
            </tbody>
          </table>
        </div>
        <p class="leading-relaxed">Every row runs its cache at q8_0. The last two rows are the same machine, the same card and the same model at the same 256k context, with only the engine changed. Decode went from 12 tokens per second to 49, and from 6 with that context nearly full to 45. Prefill went from 55 to 997. That is the whole argument for the rest of this post. gimel is next on the list.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">How a 125B model fits on a 3060</h2>
        <p class="leading-relaxed">Qwen3.8-Flash-Next is a mixture of experts: 24,576 of them, and a single token only routes through about 10. That is what makes the model cheap per token and expensive to store. <a href="https://github.com/Niko1221/Strata" class="font-semibold text-link hover:text-ocean-dark">Strata</a> is an MIT licensed inference engine built around that shape, and the way it makes the model fit is a placement decision rather than a smaller model:</p>
        <ul class="space-y-2 pl-5 list-disc marker:text-link">
          <li class="leading-relaxed">The few thousand experts used most often sit on the graphics card, which is the only part that has to be fast.</li>
          <li class="leading-relaxed">All of them sit in the 64 GB of system RAM, and the processor works on the ones the card is not holding at the same time.</li>
          <li class="leading-relaxed">A 29 GB lookup table stays on the SSD and is only read when an expert is needed that RAM is not holding.</li>
          <li class="leading-relaxed">A small draft model guesses the next few words and the big model checks them in one pass, which the project measures at 1.6 to 1.8 times faster for the same answer.</li>
        </ul>
        <p class="leading-relaxed">The published figures are for an RTX 5070: 53 tokens per second of decode and 1,620 per second reading a prompt at <code class="font-mono text-sm text-ocean-dark">IQ3_S</code>. My card is a 3060 with 12 GB, and the table above is what that actually gives. The CPU matters more than it looks in that number, because the processor is not just orchestrating, it is holding part of the model.</p>
        <p class="leading-relaxed">The same reasoning explains the row above it. A 35B model at Q4_K_M is around 21 GB with a 32k context, which does not fit in 12 GB of VRAM either, so llama.cpp splits it between the card and RAM and runs it there. The difference is that a dense model has no experts to place selectively, so the split costs more.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The laptop throttles</h2>
        <p class="leading-relaxed">10 tokens per second out of a CPU-only 35B is what an i7-8650U gives, and the ceiling there is heat rather than compute. The AVX2 load throttles the chip, so the rate you see is a sustained thermal number, not the one a short benchmark prints. That is why the laptop runs the small model and answers quickly about small things, and anything that needs a large context goes to a box with a card in it.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Context length has to match on both sides</h2>
        <p class="leading-relaxed">The expensive part of running an agent locally is not the model, it is that a harness resends its instructions, its tool catalogue and its history on every step. In measurements I took for a session I ran in August 2026, a trivial task cost 14,700 input tokens because about 25 tool schemas go back with every step. In the same set of runs, a small model at 2.2 tokens per second on CPU never finished an agent turn at all.</p>
        <p class="leading-relaxed">Two rules came out of that, and both are configuration rather than modelling:</p>
        <ul class="space-y-2 pl-5 list-disc marker:text-link">
          <li class="leading-relaxed">The context a harness declares must match the context the server was started with. If the harness thinks it has more room than the server allocated, the middle of the prompt is dropped silently.</li>
          <li class="leading-relaxed">Prefill is what an agent pays for, so a fast prefill changes what is pleasant to use before it changes what the model can reason about. That is the number that moved most when I changed engine: 55 to 997 tokens per second.</li>
        </ul>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">A Robotech formation from the same material</h2>
        <p class="leading-relaxed">Writing this down for other people is what made it stick. I gave a Robotech formation, <em>Des LLM aux agents locaux</em>, built from the same material in three parts: what happens inside a model (tokens, embeddings, attention as queries, keys and values, why the KV cache makes context expensive), how to run one yourself (llama.cpp and GGUF, the quantization table, sizing weights plus KV cache against a machine, then a live <code class="font-mono text-sm text-ocean-dark">llama-server</code>), and what an agent harness adds on top (the loop, function calling, MCP, skills, <code class="font-mono text-sm text-ocean-dark">AGENTS.md</code>, and pointing DeepSeek Harness at a local endpoint).</p>
        <p class="leading-relaxed">The exercise is the part I am happiest with: three machines, one with no GPU, one with an RTX 3060 and 12 GB, one with 8 GB of unified memory, and the task is to pick model, quantization and context for each and justify it in one line against the budget. The deck, the demo scripts and the model landscape slide are in the <a href="https://github.com/Robotech-Lillois/Formation/blob/main/formationLLM/formation_llm.tex" class="font-semibold text-link hover:text-ocean-dark">Robotech Formation repository</a>, CC BY-NC-SA 4.0, with the transformer figures from 3Blue1Brown.</p>

        <div class="rounded-card border border-ocean/20 bg-ocean/5 p-5 text-sm">
          <p class="font-semibold text-ocean-dark mb-1">If you are sizing your own box</p>
          <ul class="space-y-1 text-slate-600">
            <li>• Take the largest model family your machine holds at Q4_K_M, then decide the context before you decide anything else. The KV cache is not free and an agent needs a generous one.</li>
            <li>• Measure prefill and decode separately. If you only look at tokens per second of generation you will pick the wrong engine for agent work.</li>
            <li>• On a mixture of experts, the engine's placement strategy matters more than the raw card. A 12 GB card with 64 GB of RAM and a fast CPU beat what I expected from that card.</li>
            <li>• Make the harness context and the server context the same number, and check it after every change to either.</li>
            <li>• On a laptop CPU, plan around heat. A sustained rate is lower than a benchmark rate, and the difference is what you will actually feel.</li>
          </ul>
        </div>

        <h2 class="mt-2 text-2xl font-display font-semibold text-ocean-dark">Further reading</h2>
        <ul class="space-y-2">
          <li><a href="https://github.com/Niko1221/Strata" class="font-semibold text-link hover:text-ocean-dark">Strata</a>: the engine, and its <a href="https://github.com/Niko1221/Strata/blob/main/docs/MODELS.md" class="font-semibold text-link hover:text-ocean-dark">model and size page</a> for what fits in how much RAM.</li>
          <li><a href="https://github.com/ikawrakow/ik_llama.cpp" class="font-semibold text-link hover:text-ocean-dark">ik_llama.cpp</a>: the fork I build on the laptop, with the CPU variant kernels part 6 explains.</li>
          <li><a href="https://github.com/Robotech-Lillois/Formation/blob/main/formationLLM/formation_llm.tex" class="font-semibold text-link hover:text-ocean-dark">Formation Robotech: Des LLM aux agents locaux</a>: the deck this post is a written version of.</li>
          <li><a href="https://www.3blue1brown.com/lessons/attention" class="font-semibold text-link hover:text-ocean-dark">3Blue1Brown on attention</a>: the explanation the first part of the formation is built on.</li>
        </ul>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="font-semibold text-link hover:text-ocean-dark">Running the model myself</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/what-a-harness-is-learned-the-slow-way.html" class="font-semibold text-link hover:text-ocean-dark">What a harness is, learned the slow way</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">The throughput figures are my own readings from daily use on the machines named in the table. The 1.6 to 1.8x draft figure and the RTX 5070 numbers are Strata's published measurements.</p>
        </div>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>Running the model myself</title>
      <link>https://bouallegui.pages.dev/posts/running-the-model-myself.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/running-the-model-myself.html</guid>
      <pubDate>Sun, 13 Sep 2026 12:00:00 GMT</pubDate>
      <description>ik_llama.cpp built with CPU flags for the hardware I actually have, serving an OpenAI-compatible API to a chat interface.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">Using a hosted model means sending what you type to someone else's server, accepting their uptime, and accepting that the model can change without telling you. Running one yourself removes those three things, and costs you the hardware plus some patience. This is how mine is set up.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The engine, built for this hardware</h2>
        <p class="leading-relaxed">Inference runs through <a href="https://github.com/ikawrakow/ik_llama.cpp" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">ik_llama.cpp</a>, a performance-focused fork of <code class="font-mono text-sm text-ocean-dark">llama.cpp</code>, built from source with the CPU variant kernels compiled in rather than using a generic prebuilt binary:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">services.llama-cpp.package =
  inputs.ik-llama.packages.${pkgs.stdenv.hostPlatform.system}.default.overrideAttrs (old: {
    cmakeFlags = old.cmakeFlags ++ [
      "-DGGML_CPU_ALL_VARIANTS=ON"
      "-DGGML_BACKEND_DL=ON"
    ];
  });</code></pre>
        <p class="leading-relaxed">On a machine where the CPU is the bottleneck, that is the difference between a model that runs and a model that runs as fast as this particular CPU allows.</p>
        <p class="leading-relaxed">The reason it is cheap to experiment with is that the build is declarative. Change the flags, rebuild, compare tokens per second, keep the faster result. There is no manual compile step to remember and redo later.</p>
        <p class="leading-relaxed">Which engine to run, which quantization, and what each of my machines delivers in tokens per second is a separate note: <a href="https://bouallegui.pages.dev/posts/getting-a-125b-model-onto-a-12gb-card.html" class="font-semibold text-link hover:text-ocean-dark">Getting a 125B model onto a 12 GB card</a>.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">An OpenAI-compatible API and a chat UI</h2>
        <p class="leading-relaxed">The model serves an OpenAI-compatible API locally, so it plugs into a normal chat interface instead of living inside a curl command or a bare prompt: conversation history, more than one provider side by side, the same interface you would use with a hosted model, pointed at a local machine.</p>
        <p class="leading-relaxed">If you set this up, keep the API OpenAI-compatible and let the chat UI be a separate program. Then changing the engine and changing the interface stay two different edits.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Why bother when hosted models are better</h2>
        <p class="leading-relaxed">Mine is not as capable as a large hosted model, and I am not trying to replace that. What I get is something that works with no connection, that answers to nobody else's rate limits, and that does not change because an upstream decided to. It is the same reason as the rest of this config: I would rather understand and keep a piece of my own workflow than depend on a server I do not control, when a machine I already own can do the job.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/posts/an-editor-built-up-from-nothing.html" class="card card-link p-5" aria-label="Previous part">
            <span class="eyebrow block">Part 5</span>
            <span class="mt-1 block font-display font-semibold leading-snug">An editor built up from nothing, on purpose</span>
          </a>
          <a href="https://bouallegui.pages.dev/posts/one-flake-three-machines.html" class="card card-link p-5 text-right" aria-label="Next part">
            <span class="eyebrow block">Part 7</span>
            <span class="mt-1 block font-display font-semibold leading-snug">One flake, three machines that barely resemble each other</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/secrets-with-keys-i-already-had.html" class="font-semibold text-link hover:text-ocean-dark">Secrets, kept with keys I already had</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/my-shell-is-a-package.html" class="font-semibold text-link hover:text-ocean-dark">My shell is a package</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 6 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#self-hosted" class="font-semibold text-link hover:text-ocean-dark">Self-hosted server infrastructure</a> · <a href="https://bouallegui.pages.dev/portfolio.html#nix-monorepo" class="font-semibold text-link hover:text-ocean-dark">One flake for every machine I run</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>The wifi module stopped shelling out to iw</title>
      <link>https://bouallegui.pages.dev/posts/nl80211-wireless-detection.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/nl80211-wireless-detection.html</guid>
      <pubDate>Fri, 21 Aug 2026 12:00:00 GMT</pubDate>
      <description>Two commits in the lili wifi module: interface discovery over an nl80211 dump, then replacing every fork and exec of iw and ip with genl and rtnetlink calls.</description>
      <content:encoded><![CDATA[<figure class="container mt-10 px-5 md:px-8">
        <img src="https://bouallegui.pages.dev/images/projects/wireless.svg" alt="Diagram: on the left the wifi module forks and execs iw and ip before reaching the kernel; on the right it sends nl80211 and rtnetlink messages over one socket" width="640" height="360" loading="lazy" decoding="async" class="w-full max-w-3xl rounded-xl shadow-soft mx-auto" data-lightbox="img">
        <figcaption class="mt-2 text-center text-sm text-slate-500">Before: the module asks two other programs to change the radio. After: it sends the messages itself.</figcaption>
      </figure>

      <div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">lili is the device detection project I worked on during the research internship: several probes (wifi, bluetooth, lora, gps) listen for objects nearby and publish what they hear to an MQTT bus. I worked on the wifi module, and the change I got the most out of is a deletion. The module used to reconfigure a wireless interface by starting another program. Two commits in July 2026 removed that habit. The code sits in the university GitLab and is not public, so everything quoted below is quoted from it.</p>
        <p class="leading-relaxed">The history in that repository goes back to 2021, so the module I joined had a few years of decisions in it, and calling <code class="font-mono text-sm text-ocean-dark">iw</code> was a reasonable one. It is quick to write and easy to read. It is also a subprocess, a PATH lookup, a dependency on a program being installed on the probe, and an exit status as the only way to find out what went wrong.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What the module does</h2>
        <p class="leading-relaxed">It creates a monitor interface (<code class="font-mono text-sm text-ocean-dark">mon0</code> on <code class="font-mono text-sm text-ocean-dark">phy0</code> by default), captures on it with pcap, and checks that the datalink is <code class="font-mono text-sm text-ocean-dark">DLT_IEEE802_11_RADIO</code> before trusting anything. Each frame goes through a radiotap iterator and then an 802.11 management frame parser, which keeps two linked lists: devices and access points, each holding a MAC and the last signal level seen. Three MQTT topics carry the result. <code class="font-mono text-sm text-ocean-dark">wifi/raw</code> gets the frame itself, <code class="font-mono text-sm text-ocean-dark">wifi/dev/new</code>, <code class="font-mono text-sm text-ocean-dark">upd</code> and <code class="font-mono text-sm text-ocean-dark">del</code> get the objects, and <code class="font-mono text-sm text-ocean-dark">wifi/net/link</code> gets a pair of ids that were seen talking.</p>
        <p class="leading-relaxed">The radio does not stay put. A detached thread walks a FIFO of 31 channels across 2.4 and 5 GHz, sleeping 4 seconds between switches, so a full sweep takes just over two minutes.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The hot path was a subprocess</h2>
        <p class="leading-relaxed">That thread called <code class="font-mono text-sm text-ocean-dark">set_channel</code> every four seconds. This is what <code class="font-mono text-sm text-ocean-dark">set_channel</code> was:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">int set_channel(const char *iface, unsigned char chan)
{
    pid_t child;
    int status = 0;
    char str_chan[8] = {0};
    if (chan &lt; 1 || chan &gt; 140) {
        fprintf(stderr,
            "Tentative d'utiliser le channel WiFi %hhu, "
            "non-existant.\n",
            chan);
        return -1;
    }
    snprintf(str_chan, sizeof str_chan, "%hhu", chan);

    if (veryverbose) {
        printf("Execution de `iw dev %s set channel %s`\n", iface,
               str_chan);
    }
    if ((child = fork()) == 0) {
        execlp("iw", "iw", "dev", iface, "set", "channel", str_chan,
               NULL);
    }
    waitpid(child, &amp;status, 0);
    return WEXITSTATUS(status);
}</code></pre>

        <p class="leading-relaxed">A fork, an exec of whatever <code class="font-mono text-sm text-ocean-dark">iw</code> is first on PATH, a waitpid, and a <code class="font-mono text-sm text-ocean-dark">WEXITSTATUS</code>. Fifteen times a minute, about 900 times an hour, to hand the kernel something it exposes as a single netlink message.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Setup did it three more times</h2>
        <p class="leading-relaxed">Bringing the capture interface up was the same pattern, repeated:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">if ((child = fork()) == 0) {
        PERR_CALL_L0(execlp("iw", "iw", "phy", physical_iface,
                    "interface", "add", monitor_iface, "type",
                    "monitor", NULL));
    }
    waitpid(child, NULL, 0); // TODO: verifier echec

    for (char **iface = wifi_ifaces; *iface != NULL; iface++) {
        if (verbose) {
            printf("Desactivation de l'interface %s\n", *iface);
            printf("`ip link set %s down`\n", *iface);
        }
        if ((child = fork()) == 0) {
            PERR_CALL_L0(execlp("ip", "ip", "link", "set", *iface,
                        "down", NULL));
        }
        waitpid(child, NULL, 0);
    }

    if (verbose) {
        printf("Activation de l'interface %s\n", monitor_iface);
        printf("`ip link set %s up`\n", monitor_iface);
    }
    if ((child = fork()) == 0) {
        PERR_CALL_L0(execlp("ip", "ip", "link", "set", monitor_iface,
                    "up", NULL));
    }
    waitpid(child, NULL, 0);</code></pre>

        <p class="leading-relaxed">Five <code class="font-mono text-sm text-ocean-dark">execlp</code> sites in the file. The error handling was a comment: <code class="font-mono text-sm text-ocean-dark">waitpid(child, NULL, 0); // TODO: verifier echec</code>. The list of interfaces to bring down was also hardcoded as <code class="font-mono text-sm text-ocean-dark">{"wlan0", NULL}</code>, which stops being funny the first time someone runs a probe with a USB adapter, because those come up named after their MAC. The config in the repository points at one such <code class="font-mono text-sm text-ocean-dark">wlx...</code> interface.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">First commit: ask the kernel which interfaces exist</h2>
        <p class="leading-relaxed"><code class="font-mono text-sm text-ocean-dark">NL80211_CMD_GET_INTERFACE</code> sent with <code class="font-mono text-sm text-ocean-dark">NLM_F_DUMP | NLM_F_REQUEST</code> gives one netlink message back per wireless interface. libnl hands each one to a callback, and the callback reads <code class="font-mono text-sm text-ocean-dark">NL80211_ATTR_IFNAME</code> and strdups it into a NULL-terminated array capped at 5 entries. That is the whole trick, and it replaced a hardcoded name.</p>
        <p class="leading-relaxed">The commit is 160 lines, and a suspicious number of them are socket boilerplate: allocate, <code class="font-mono text-sm text-ocean-dark">genl_connect</code>, <code class="font-mono text-sm text-ocean-dark">genl_ctrl_resolve</code>, allocate a message, install the callback, send, receive, free. The second commit pulled all of that into one place.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Second commit: the state changes go through netlink as well</h2>
        <p class="leading-relaxed">437 insertions and 262 deletions across four files. It starts with a session object, opened once in <code class="font-mono text-sm text-ocean-dark">main_init</code> and closed in <code class="font-mono text-sm text-ocean-dark">main_cleanup</code>:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">struct nl80211_session {
    struct nl_sock *sock;
    int id;    // id de la famille nl80211
};

static int nl80211_session_init(struct nl80211_session *s)
{
    int ret;

    s-&gt;sock = nl_socket_alloc();
    if (!s-&gt;sock) {
        fprintf(stderr, "Impossible d'allouer un socket netlink\n");
        return -1;
    }

    ret = genl_connect(s-&gt;sock);
    if (ret &lt; 0) {
        fprintf(stderr, "Impossible de connecter le socket netlink (code %d)\n", ret);
        nl_socket_free(s-&gt;sock);
        return ret;
    }

    ret = genl_ctrl_resolve(s-&gt;sock, "nl80211");
    if (ret &lt; 0) {
        fprintf(stderr, "Famille nl80211 non trouvee (code %d)\n", ret);
        nl_socket_free(s-&gt;sock);
        return ret;
    }
    s-&gt;id = ret;

    return 0;
}</code></pre>

        <p class="leading-relaxed">Five nl80211 commands cover everything the module needs:</p>
        <ul class="space-y-2 list-disc list-inside">
          <li><code class="font-mono text-sm text-ocean-dark">GET_INTERFACE</code>, dumped, to list what is on the machine.</li>
          <li><code class="font-mono text-sm text-ocean-dark">GET_WIPHY</code>, dumped, to turn a name like <code class="font-mono text-sm text-ocean-dark">phy0</code> into the u32 index nl80211 wants.</li>
          <li><code class="font-mono text-sm text-ocean-dark">NEW_INTERFACE</code> with <code class="font-mono text-sm text-ocean-dark">WIPHY</code>, <code class="font-mono text-sm text-ocean-dark">IFNAME</code> and <code class="font-mono text-sm text-ocean-dark">IFTYPE</code> set to <code class="font-mono text-sm text-ocean-dark">NL80211_IFTYPE_MONITOR</code>.</li>
          <li><code class="font-mono text-sm text-ocean-dark">DEL_INTERFACE</code> with an ifindex, called at startup in case <code class="font-mono text-sm text-ocean-dark">mon0</code> already exists and again in <code class="font-mono text-sm text-ocean-dark">main_cleanup</code>.</li>
          <li><code class="font-mono text-sm text-ocean-dark">SET_WIPHY</code> with <code class="font-mono text-sm text-ocean-dark">WIPHY_FREQ</code> and <code class="font-mono text-sm text-ocean-dark">CHANNEL_WIDTH</code>, which is the one the hopping thread runs every four seconds.</li>
        </ul>

        <p class="leading-relaxed">They all have the same shape:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">static int nl80211_create_interface(struct nl80211_session *s, int wiphy_idx,
                                    const char *ifname, enum nl80211_iftype type)
{
    struct nl_msg *msg;
    int ret;

    msg = nlmsg_alloc();
    if (!msg) return -1;

    genlmsg_put(msg, NL_AUTO_PORT, NL_AUTO_SEQ, s-&gt;id, 0, 0,
                NL80211_CMD_NEW_INTERFACE, 0);

    NLA_PUT_U32(msg, NL80211_ATTR_WIPHY, wiphy_idx);
    NLA_PUT_STRING(msg, NL80211_ATTR_IFNAME, ifname);
    NLA_PUT_U32(msg, NL80211_ATTR_IFTYPE, type);

    ret = nl_send_auto(s-&gt;sock, msg);
    if (ret &lt; 0) {
        fprintf(stderr, "nl80211_create_interface: send error %d\n", ret);
        nlmsg_free(msg);
        return ret;
    }

    ret = nl_recvmsgs_default(s-&gt;sock);
    if (ret &lt; 0)
        fprintf(stderr, "nl80211_create_interface: recv error %d\n", ret);

    nlmsg_free(msg);
    return ret;

nla_put_failure:
    nlmsg_free(msg);
    return -NLE_NOMEM;
}</code></pre>

        <p class="leading-relaxed">Two things in there are worth knowing before you write your own. <code class="font-mono text-sm text-ocean-dark">NLA_PUT_U32</code> is a macro that jumps to a label called <code class="font-mono text-sm text-ocean-dark">nla_put_failure</code> when an attribute does not fit, which is why every helper ends with that label returning <code class="font-mono text-sm text-ocean-dark">-NLE_NOMEM</code>. And nl80211 addresses interfaces by index, so <code class="font-mono text-sm text-ocean-dark">if_nametoindex</code> sits at the top of the ones that take a name.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Bringing an interface up is a different family</h2>
        <p class="leading-relaxed">nl80211 creates the interface. It does not set <code class="font-mono text-sm text-ocean-dark">IFF_UP</code>, because that belongs to rtnetlink, so the two <code class="font-mono text-sm text-ocean-dark">ip link set</code> calls became one helper over libnl-route:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">// Recuperation du lien actuel
    struct rtnl_link *link;
    ret = rtnl_link_get_kernel(sk, 0, ifname, &amp;link);
    if (ret &lt; 0) {
        fprintf(stderr, "rtnl_link_set_up: cannot get link %s (err %d)\n", ifname, ret);
        nl_socket_free(sk);
        return ret;
    }

    // Creation d'une copie pour appliquer les modifications
    struct rtnl_link *new_link = rtnl_link_alloc();
    if (!new_link) {
        rtnl_link_put(link);
        nl_socket_free(sk);
        return -NLE_NOMEM;
    }

    // Copie des proprietes existantes
    rtnl_link_set_ifindex(new_link, rtnl_link_get_ifindex(link));
    unsigned int flags = rtnl_link_get_flags(link);
    if (up)
        flags |= IFF_UP;
    else
        flags &amp;= ~IFF_UP;
    rtnl_link_set_flags(new_link, flags);

    // Passage du lien original (non modifie) et du nouveau lien modifie
    ret = rtnl_link_change(sk, link, new_link, 0);</code></pre>

        <p class="leading-relaxed">libnl wants the original object and the modified one so it can work out the difference itself, which is why the code copies the ifindex and the flags into a fresh <code class="font-mono text-sm text-ocean-dark">rtnl_link</code> before flipping the bit.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Channels became frequencies</h2>
        <p class="leading-relaxed">The set path in nl80211 has no notion of a channel number. It takes <code class="font-mono text-sm text-ocean-dark">NL80211_ATTR_WIPHY_FREQ</code> in MHz, so the FIFO of channels became a FIFO of frequencies and one small function appeared:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">static uint32_t channel_to_freq(unsigned int channel)
{
    if (channel &gt;= 1 &amp;&amp; channel &lt;= 14)
        return 2407 + channel * 5;   // bande 2.4 GHz
    else if (channel &gt;= 36 &amp;&amp; channel &lt;= 165)
        return 5000 + channel * 5;   // bande 5 GHz
    else
        return 0; // invalide
}</code></pre>

        <p class="leading-relaxed">The 5 GHz line looks wrong for a second. It is not: channel 36 is 5180 MHz, and 5000 plus 36 times 5 is exactly that.</p>

        <p class="leading-relaxed">The hopping thread kept its shape and lost the fork:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">void *channel_handler_run(__attribute__((unused)) void *v)
{
    uint32_t current_freq = START_FREQ;
    while (!quit) {
        current_freq = get_next_freq(&amp;fifo);
        usleep(channel_switch_delay);
        if (nl_session_ptr)
            nl80211_set_freq(nl_session_ptr, monitor_iface, current_freq,
                             NL80211_CHAN_WIDTH_20_NOHT);
    }
    return NULL;
}</code></pre>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What the rewrite is worth</h2>
        <p class="leading-relaxed">I did not benchmark it, so there is no before and after number here. What changed is structural. The steady state no longer creates a process every four seconds. The module no longer needs <code class="font-mono text-sm text-ocean-dark">iw</code> and <code class="font-mono text-sm text-ocean-dark">ip</code> installed to run. A failure comes back as a libnl error code printed next to the call that produced it, instead of an exit status from a child process.</p>
        <p class="leading-relaxed">The <code class="font-mono text-sm text-ocean-dark">geteuid() != 0</code> check that used to sit in <code class="font-mono text-sm text-ocean-dark">monitor_setup</code> went away with the exec calls. The netlink send fails on its own now and prints its own error. I think that is the better trade, but it is harder to read when it happens over SSH on a machine you have not configured.</p>
        <p class="leading-relaxed">Two things are still open in that file. <code class="font-mono text-sm text-ocean-dark">main_cleanup</code> ends with <code class="font-mono text-sm text-ocean-dark">//TODO (OPTIONNEL) liberer les interfaces wifi aussi</code>, and the doc comment on the interface listing still carries <code class="font-mono text-sm text-ocean-dark">\todo Séparer la sortie par interface physique</code>, which starts to matter as soon as a probe has more than one radio. The header is also still declaring <code class="font-mono text-sm text-ocean-dark">set_freq</code> and <code class="font-mono text-sm text-ocean-dark">next_channel</code>, and neither has a definition anywhere in the tree any more.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Building just this module</h2>
        <p class="leading-relaxed">The radiotap library the parser links against is packaged in Kali and, as far as I could find, nowhere convenient, so the README tells you to clone it and run cmake yourself. <code class="font-mono text-sm text-ocean-dark">wifi/flake.nix</code> builds this module on its own: it fetches radiotap-library into a derivation, writes a <code class="font-mono text-sm text-ocean-dark">lili.h</code> holding the handful of macros the module expects, and compiles three translation units against libnl, mosquitto, libpcap and cjson. It is still untracked in the repository, so treat it as scratch work rather than something the project has adopted.</p>

        <div class="rounded-card border border-ocean/20 bg-ocean/5 p-5 text-sm">
          <p class="font-semibold text-ocean-dark mb-1">If you are making the same replacement</p>
          <ul class="space-y-1 text-slate-600">
            <li>• Do the read-only commands first. A <code class="font-mono text-xs">GET_INTERFACE</code> dump gives you a socket, a family id and a callback in one commit, and nothing is damaged if you get it wrong.</li>
            <li>• Keep one socket for the life of the process. The first version resolved the family inside the listing function, which is fine once and wasteful in a loop.</li>
            <li>• Work out which netlink family owns the operation before searching for a command. Interface creation is nl80211, <code class="font-mono text-xs">IFF_UP</code> is rtnetlink, and looking for an nl80211 command for the second one costs an afternoon.</li>
            <li>• Read <code class="font-mono text-xs">include/uapi/linux/nl80211.h</code>. The attribute names live there and it is the only place they are authoritative.</li>
          </ul>
        </div>

        <h2 class="mt-2 text-2xl font-display font-semibold text-ocean-dark">Further reading</h2>
        <ul class="space-y-2">
          <li><a href="https://docs.kernel.org/80211/nl80211.html" class="font-semibold text-link hover:text-ocean-dark">The kernel's nl80211 documentation</a>: what each command expects and which attributes are required.</li>
          <li><a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/uapi/linux/nl80211.h" class="font-semibold text-link hover:text-ocean-dark"><code class="font-mono text-sm">include/uapi/linux/nl80211.h</code></a>: the enums and attribute names.</li>
          <li><a href="https://www.infradead.org/~tgr/libnl/doc/core.html" class="font-semibold text-link hover:text-ocean-dark">libnl core documentation</a>: sockets, callbacks and the error codes.</li>
          <li><a href="https://www.infradead.org/~tgr/libnl/doc/route.html" class="font-semibold text-link hover:text-ocean-dark">libnl-route documentation</a>: the rtnetlink objects used to change link flags.</li>
        </ul>


        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/a-keyboard-that-took-out-its-own-mcu.html" class="font-semibold text-link hover:text-ocean-dark">A keyboard that took out its own MCU</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/a-private-cloud-with-a-guest-list.html" class="font-semibold text-link hover:text-ocean-dark">A private cloud, sized for the people who actually use it</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">The config those machines run is written up in <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a> (eight parts).</p>
        </div>

        <p class="text-sm text-slate-500">Where this fits: <a href="https://bouallegui.pages.dev/portfolio.html#wireless" class="font-semibold text-link hover:text-ocean-dark">Wireless detection over nl80211</a> in the portfolio.</p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>My shell is a package</title>
      <link>https://bouallegui.pages.dev/posts/my-shell-is-a-package.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/my-shell-is-a-package.html</guid>
      <pubDate>Sun, 12 Jul 2026 12:00:00 GMT</pubDate>
      <description>The shell as a derivation: one wrapped package that every entry point resolves to, with git identity set at build time.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">Most shell setups are a <code class="font-mono text-sm text-ocean-dark">.bashrc</code> or a <code class="font-mono text-sm text-ocean-dark">config.fish</code> accumulated over years, sourced at login, half remembered. Mine is a Nix derivation. Not a shell with a Nix-managed config file: the shell itself, together with every tool I expect on <code class="font-mono text-sm text-ocean-dark">PATH</code> the moment I log in, is one package.</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">packages.environment = inputs.wrappers.lib.wrapPackage {
  inherit pkgs;
  package = self'.packages.fish;
  runtimeInputs = [
    # nix tooling
    pkgs.nil pkgs.nixd pkgs.statix pkgs.alejandra pkgs.manix pkgs.nix-inspect pkgs.nh

    # the rest of the toolbelt
    pkgs.fzf pkgs.eza pkgs.fd pkgs.zoxide pkgs.ripgrep pkgs.lazygit
    pkgs.btop pkgs.imagemagick pkgs.ffmpeg-full pkgs.yt-dlp pkgs.wl-clipboard

    # wrapped packages, themed and configured in their own right
    self'.packages.neovim self'.packages.qalc self'.packages.git self'.packages.yazi
  ];
};</code></pre>
        <p class="leading-relaxed">That is not a list of things I installed at some point. It is a list of things that are present as soon as this package is built, on any machine, because <code class="font-mono text-sm text-ocean-dark">wrapPackage</code> produces a fish binary with all of it in its own <code class="font-mono text-sm text-ocean-dark">PATH</code> before any config file is read.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">One package, everywhere a shell appears</h2>
        <p class="leading-relaxed">The benefit is not really tool availability, which plenty of setups handle with a list in <code class="font-mono text-sm text-ocean-dark">environment.systemPackages</code>. The benefit is that one derivation is used everywhere a shell is needed, instead of each entry point having its own slightly different idea of what my shell is:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed"># the terminal's default shell
packages.terminal = (inputs.wrappers.wrapperModules.kitty.apply {
  imports = [ self.wrappersModules.kitty ];
  shell = lib.getExe self'.packages.environment;
}).wrapper;</code></pre>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed"># the actual login shell for the user account
users.users.&lt;user&gt;.shell = self.packages.${pkgs.system}.environment;</code></pre>
        <p class="leading-relaxed">Open a terminal and you get this shell. Log in at a TTY and you get this shell. SSH into the server on the account that has it set and you get this shell. One definition, referenced from three places, so there is nothing to keep in step.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Git's identity is in here too</h2>
        <p class="leading-relaxed">The same wrapping pattern applies to git. Authorship is set as environment variables on the wrapped binary rather than in <code class="font-mono text-sm text-ocean-dark">~/.gitconfig</code>:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">packages.git = inputs.wrappers.lib.wrapPackage {
  package = pkgs.git;
  env = {
    GIT_AUTHOR_NAME = "&lt;name&gt;";
    GIT_AUTHOR_EMAIL = "ahmedtbou@tutamail.com";
  };
};</code></pre>
        <p class="leading-relaxed">The practical effect shows up when you set up a new machine. There is no dotfile to copy, and no window where commits go out with the wrong address because the config had not been written yet. The <code class="font-mono text-sm text-ocean-dark">git</code> on <code class="font-mono text-sm text-ocean-dark">PATH</code> already knows who is using it.</p>
        <p class="leading-relaxed">One caveat. Environment variables baked into a wrapper do not appear in <code class="font-mono text-sm text-ocean-dark">git config --list</code>, so if you ever debug a wrong commit author, look at the wrapper before you look at your gitconfig.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/posts/one-flake-three-machines.html" class="card card-link p-5" aria-label="Previous part">
            <span class="eyebrow block">Part 7</span>
            <span class="mt-1 block font-display font-semibold leading-snug">One flake, three machines that barely resemble each other</span>
          </a>
          <a href="https://bouallegui.pages.dev/blog.html" class="card card-link p-5 text-right">
            <span class="eyebrow block">The series</span>
            <span class="mt-1 block font-display font-semibold leading-snug">All eight parts</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/an-editor-built-up-from-nothing.html" class="font-semibold text-link hover:text-ocean-dark">An editor built up from nothing, on purpose</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/a-desktop-that-scrolls.html" class="font-semibold text-link hover:text-ocean-dark">A desktop that scrolls instead of stacking</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 8 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#nix-monorepo" class="font-semibold text-link hover:text-ocean-dark">One flake for every machine I run</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>A split keyboard that took out its own microcontroller</title>
      <link>https://bouallegui.pages.dev/posts/a-keyboard-that-took-out-its-own-mcu.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/a-keyboard-that-took-out-its-own-mcu.html</guid>
      <pubDate>Sun, 05 Jul 2026 12:00:00 GMT</pubDate>
      <description>Coursework build log: one half of a 30-key split keyboard on two copper layers, ATmega32U4 with LUFA HID, 16 RGB LEDs on two cascaded TLC5947 drivers, and the backlight current that killed the MCU.</description>
      <content:encoded><![CDATA[<figure class="container mt-10 px-5 md:px-8">
        <img src="https://bouallegui.pages.dev/images/projects/keyboard.jpg" alt="The built keyboard half on green solder mask, partially populated with mechanical switches in hot-swap sockets, with a USB plug soldered to the board edge" width="868" height="1156" loading="lazy" decoding="async" class="w-full max-w-md rounded-xl shadow-soft mx-auto" data-lightbox="img">
        <figcaption class="mt-2 text-center text-sm text-slate-500">One half, part-populated. The USB-A plug is on the board itself, so it goes straight into a port.</figcaption>
      </figure>

      <div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">Third year had an embedded systems project: design and build a device, PCB included. We built one half of a split ergonomic keyboard. Thirty keys, per-key RGB, a board we routed ourselves. Somewhere between the soldering sessions and the first working animation, the backlight took out the microcontroller.</p>
        <p class="leading-relaxed">What follows is what I would keep in that design, what I would not, and the calculation that should have happened before we ordered the parts. The code is coursework and stays private; the wiki page linked at the end is the public record of it.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What is on the board</h2>
        <p class="leading-relaxed">The KiCad project carries 112 footprints on two copper layers. Two layers was a constraint we set at the start, one for the switch matrix and one for the LEDs, and it turned out to be routable.</p>

        <div class="overflow-x-auto">
          <table class="w-full text-sm">
            <thead><tr class="text-left font-display font-semibold text-ocean-dark"><th class="pr-4 py-2">Block</th><th class="pr-4 py-2">Part</th><th class="py-2">Notes</th></tr></thead>
            <tbody class="align-top">
              <tr class="border-t border-slate-200"><td class="pr-4 py-2">MCU</td><td class="pr-4 py-2">ATmega32U4, 16 MHz crystal</td><td class="py-2">Hardware USB, so HID needs no extra chip</td></tr>
              <tr class="border-t border-slate-200"><td class="pr-4 py-2">Keys</td><td class="pr-4 py-2">30 Kailh MX hot-swap sockets</td><td class="py-2">Sockets are soldered, switches press into them, so a switch can be changed without an iron</td></tr>
              <tr class="border-t border-slate-200"><td class="pr-4 py-2">Matrix</td><td class="pr-4 py-2">30 diodes, 0603</td><td class="py-2">One per switch, against ghosting when several keys are held</td></tr>
              <tr class="border-t border-slate-200"><td class="pr-4 py-2">Backlight</td><td class="pr-4 py-2">16 APFA3010 RGB, 2 TLC5947</td><td class="py-2">48 channels of 12-bit PWM, the two drivers cascaded</td></tr>
              <tr class="border-t border-slate-200"><td class="pr-4 py-2">Power</td><td class="pr-4 py-2">MAX1811 charger, MCP1640 boost</td><td class="py-2">Li-ion in, 5 V regulated out, USB as the other source</td></tr>
              <tr class="border-t border-slate-200"><td class="pr-4 py-2">Link</td><td class="pr-4 py-2">3.5 mm jack, AVR-ISP-6 header</td><td class="py-2">UART to the other half, ISP as the programming fallback</td></tr>
            </tbody>
          </table>
        </div>

        <figure>
          <img src="https://bouallegui.pages.dev/images/projects/keyboard.svg" alt="Diagram: a 5 by 6 key matrix and 16 RGB LEDs feeding an ATmega32U4 through eleven pins, with 576 bits going out to two cascaded TLC5947 drivers, USB to the host, and a dashed UART line to the other half" width="640" height="360" loading="lazy" decoding="async" class="w-full rounded-xl shadow-soft" data-lightbox="img">
          <figcaption class="mt-2 text-center text-sm text-slate-500">The signal path. Eleven pins carry the matrix in, 576 bits carry the backlight out.</figcaption>
        </figure>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Why the backlight is not on the MCU</h2>
        <p class="leading-relaxed">Sixteen RGB LEDs is forty-eight PWM channels. Generating those in software on a 16 MHz AVR while also scanning a matrix and running a USB stack would have taken the whole semester. The TLC5947 does it on its own: 12-bit PWM generated internally at roughly 30 kHz, and the MCU only has to shift in a frame.</p>
        <p class="leading-relaxed">Two drivers are cascaded so one frame covers both. The MCU sends forty-eight channels of twelve bits, most significant bit first, starting from the last channel of the far chip, then pulses the latch line. From <code class="font-mono text-sm text-ocean-dark">tlc5947.c</code>:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">for (int8_t c = NUM_CHANNELS - 1; c &gt;= 0; c--) {
    uint16_t value = pwm_buffer[c];
    for (int8_t b = 11; b &gt;= 0; b--) {
        if (value &amp; (1 &lt;&lt; b)) PORTB |= (1 &lt;&lt; PB5);
        else                  PORTB &amp;= ~(1 &lt;&lt; PB5);
        PORTD |= (1 &lt;&lt; PD0);   // SCLK high
        PORTD &amp;= ~(1 &lt;&lt; PD0);  // SCLK low
    }
}
PORTD |= (1 &lt;&lt; PD6);   // XLAT
PORTD &amp;= ~(1 &lt;&lt; PD6);</code></pre>

        <p class="leading-relaxed">That is the whole driver: 576 bits and a latch pulse, with no timer and no interrupt involved. The frame is about 72 bytes, and at the rate the animation refreshed it cost nothing I could measure. This is the decision I would keep exactly as it is.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The backlight current, and the resistor that decided it</h2>
        <p class="leading-relaxed">A TLC5947 is a constant-current sink. One external resistor per chip sets the current for all twenty-four of its channels, and the datasheet relation is this:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">IOUT = (VRIREF / REXT) * 31.25        VRIREF = 1.2 V typical

REXT = 1.97 kOhm  ->  ~19 mA per channel  ->  48 channels  ~ 0.9 A
REXT = 3.3  kOhm  ->  ~11 mA per channel  ->  48 channels  ~ 0.55 A

what we designed the supply around: ~300 mA at 5 V for the whole board</code></pre>

        <p class="leading-relaxed">We fitted 1.97 kΩ on both IREF lines. For a single LED, 19 mA is reasonable. For forty-eight channels at once it is about 0.9 A, against a supply sized for roughly 300 mA for everything. During testing, with the brightness turned up, the ATmega32U4 died. It took us a while to accept that the chip itself was gone, and the confirmation was desoldering it and fitting a new one.</p>
        <p class="leading-relaxed">The fix was two resistors. 3.3 kΩ puts each channel near 11 mA, and nothing heats up any more. The firmware then deals with the colour imbalance, because the APFA3010 datasheet shows red being weaker than green and blue at the same current:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">// according to the datasheet, reds are much weaker than blues and greens
tlc_set_led(i, r &gt;&gt; 1, g &gt;&gt; 4, b &gt;&gt; 4);</code></pre>

        <p class="leading-relaxed">The margin is still thin, and it is worth writing down. At 11 mA per channel, sixteen LEDs at full white is around half an amp, which is still above the 300 mA we designed for. The board gets away with it because the animation never drives every channel to full. That is a workaround. A second version needs the resistor chosen from the power budget first, and for a 5 mA target that is about 7.5 kΩ.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The matrix, and the two microseconds</h2>
        <p class="leading-relaxed">Five rows and six columns is eleven pins instead of thirty. They are spread across four ports, which looks untidy in the source but was a routing decision: columns on PORTF, rows on PORTC, PORTB and PORTD, chosen so the traces could actually leave the MCU on two layers. Four of those PORTF pins are JTAG pins, so the init disables the JTAG interface to get them back.</p>
        <p class="leading-relaxed">Debounce is a shift register per key. Eight equal samples in a row change the state, and anything in between leaves it alone:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">debounce_state[r][c] = (debounce_state[r][c] &lt;&lt; 1) | (raw_state ? 1 : 0);

if (debounce_state[r][c] == 0xFF &amp;&amp; !matrix_state[r][c]) matrix_state[r][c] = true;
else if (debounce_state[r][c] == 0x00 &amp;&amp; matrix_state[r][c]) matrix_state[r][c] = false;</code></pre>

        <p class="leading-relaxed">There is one line in the scan loop I have not fully explained, and I would rather write it down than tidy it away:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">select_row(r);

// Keyboard doesn't doesn't work without this for some god forsaken reason
// hypothesis: wait a tiny bit for the voltage to drop (capacitance on the lines)
_delay_us(2);</code></pre>

        <p class="leading-relaxed">Without that delay the scan reads keys nobody pressed. My explanation is the one in the comment: the row has to actually fall, and with thirty diodes and long traces on both sides there is enough capacitance for the column to be read too early. I have never measured it. The delay costs nothing, so it stays.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The rainbow knows where each key is</h2>
        <p class="leading-relaxed">The animation sweeps hue. The usual way to do it numbers the LEDs left to right, which means the gradient follows the channel index. The channel order on the drivers has nothing to do with where the LEDs sit on the board, so that version looks scrambled once it is built.</p>
        <p class="leading-relaxed">I took the x and y coordinates of all sixteen LEDs out of the KiCad placement and put them in <code class="font-mono text-sm text-ocean-dark">main.c</code>. Each LED's hue is the global hue plus a weighted offset from its position:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">float hue = global_hue
            + alpha * (led_positions[i].x - x_ref)   // alpha = 0.5
            + beta  * (led_positions[i].y - y_ref);  // beta  = 0.3</code></pre>

        <p class="leading-relaxed">Adding an LED then means adding one coordinate pair instead of re-deriving an order. It is the part of the firmware I like most.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Soldered on the wrong face</h2>
        <p class="leading-relaxed">The switches went onto the opposite side of the board from the one the layout assumed. Reworking thirty sockets was one option. Mirroring the keymap table in firmware was the other, and we took that one, with an apology left in the source:</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">// KEYBOARD DEFINITION HERE.
// IT'S MIRRORRED BECAUSE WE MESSED UP THE FACE ON WHICH WE PUT THE BUTTONS
// SORRY.</code></pre>

        <p class="leading-relaxed">The table maps the matrix to the left half of an AZERTY layout, escape and the number row along the top, modifiers down the thumb column. One entry is not a key code but a sentinel that the report builder turns into the left-control modifier byte, so that key behaves as a modifier. The report itself is the standard HID boot keyboard report, which caps it at six simultaneous keys plus modifiers.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The link to the other half</h2>
        <p class="leading-relaxed">The two halves talk over a 3.5 mm jack using the MCU's second UART at 115200 baud. A packet is a start byte, a command, a length, the payload, and a checksum that is a plain sum modulo 256. The thirty bits of key state pack into four bytes, so a full matrix update is nine bytes on the wire.</p>

        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">0xAA | cmd | len | data[len] | checksum        cmd 0x01 = matrix, len = 4</code></pre>

        <p class="leading-relaxed">The transmit side runs every fifty loop iterations. The receive side is a byte-at-a-time parser with a length guard, and it checks the checksum before accepting anything. The step that never happened is the merge: the received matrix is not folded into what goes out over USB, and that place in the source is a literal <code class="font-mono text-sm text-ocean-dark">//TODO</code>. The other half was built by another group, and we never got the two boards working together on a desk. So this project is one working half with a link that is implemented on both ends and unused.</p>
        <p class="leading-relaxed">We may build a tidier second version some day. Reading this repo already says what would change: choose the IREF resistor from the power budget, keep the drivers and the hot-swap sockets, and finish the merge before wiring up a second board.</p>

        <div class="rounded-card border border-ocean/20 bg-ocean/5 p-5 text-sm">
          <p class="font-semibold text-ocean-dark mb-1">If you are building something like this</p>
          <ul class="space-y-1 text-slate-600">
            <li>• Work out what the driver draws before you order parts. On the TLC5947 one resistor decides it for twenty-four channels at once.</li>
            <li>• Let a dedicated chip generate the PWM. It gave the MCU back entirely, and the frame cost nothing.</li>
            <li>• Hot-swap sockets turn a soldering mistake into a short job, and the switches survive into the next project.</li>
            <li>• A layout error can be cheaper to fix in firmware than at the bench, but it leaves a comment in the source permanently.</li>
            <li>• Finish the merge between two boards before building a third thing on top of it.</li>
          </ul>
        </div>

        <h2 class="mt-2 text-2xl font-display font-semibold text-ocean-dark">Further reading</h2>
        <ul class="space-y-2">
          <li><a href="https://projets-se.plil.fr/mediawiki/index.php/SE3Groupe2025-4" class="font-semibold text-link hover:text-ocean-dark">The project wiki page</a>: the full hardware write-up in French, including the power section and the dated build log.</li>
          <li><a href="https://www.ti.com/product/TLC5947" class="font-semibold text-link hover:text-ocean-dark">TLC5947 datasheet</a>: the 24-channel driver, the IREF relation, and the frame format.</li>
          <li><a href="https://www.fourwalledcubicle.com/LUFA.php" class="font-semibold text-link hover:text-ocean-dark">LUFA</a>: the AVR USB stack the HID layer sits on.</li>
        </ul>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/nl80211-wireless-detection.html" class="font-semibold text-link hover:text-ocean-dark">The wifi module stopped shelling out to iw</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/one-flake-three-machines.html" class="font-semibold text-link hover:text-ocean-dark">One flake, three machines</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">The code for this board is coursework and stays private. The wiki page above is the public record of it.</p>
        </div>

        <p class="text-sm text-slate-500">Where this fits: <a href="https://bouallegui.pages.dev/portfolio.html#keyboard" class="font-semibold text-link hover:text-ocean-dark">A split mechanical keyboard, designed and built</a> in the portfolio.</p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>One flake, three machines that barely resemble each other</title>
      <link>https://bouallegui.pages.dev/posts/one-flake-three-machines.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/one-flake-three-machines.html</guid>
      <pubDate>Sun, 21 Jun 2026 12:00:00 GMT</pubDate>
      <description>Three machines that share almost nothing except the flake. What stays common, what stays per-host, and how to stop them drifting apart.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">I have three NixOS machines: a ThinkPad laptop, an older laptop I no longer use and keep for conservation, and a headless server I will call aleph, which has no display and is never touched directly. They share almost no hardware. All three are built from the same flake, and pointing a command at a different one is a matter of changing a single name.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What is shared</h2>
        <p class="leading-relaxed">Everything except hardware. The desktop, the editor, the terminal, the shell, the secrets plumbing and the Nix settings live in <code class="font-mono text-sm text-ocean-dark">modules/base/</code> and <code class="font-mono text-sm text-ocean-dark">modules/features/</code>, written once and imported by whichever hosts want them:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed"># Amal/configuration.nix (laptop)
imports = [ self.nixosModules.desktop self.nixosModules.gaming ... ];

# myMachine/configuration.nix (retired, kept for conservation)
imports = [ self.nixosModules.desktop ... ];</code></pre>
        <p class="leading-relaxed">Both laptops get the same niri setup, the same Neovim config and the same terminal. Not two copies that started out identical and will drift, but one module evaluated twice. Fix a bug in the desktop module and both machines have the fix on their next rebuild, with no second copy to remember.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What is not shared</h2>
        <p class="leading-relaxed">Hardware first. Each host's <code class="font-mono text-sm text-ocean-dark">hardware.nix</code> is the untouched output of <code class="font-mono text-sm text-ocean-dark">nixos-generate-config</code>, specific to that machine's disks and boot setup, and I do not hand-edit it.</p>
        <p class="leading-relaxed">Then anything that is a fact about one box rather than a preference: how many cores to use for parallel builds, which hostname a shared module should treat as "this machine" when asking the language server for completions, and where the flake happens to be checked out on disk.</p>
        <p class="leading-relaxed">Those facts come in through a small number of environment variables set once per host (<code class="font-mono text-sm text-ocean-dark">$NIXD_HOST</code>, <code class="font-mono text-sm text-ocean-dark">$NH_FLAKE</code>) instead of being written into the shared modules. The shared code stays shared, and the facts that cannot be shared stay in the one file that knows them.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Two rules that stop it drifting</h2>
        <p class="leading-relaxed">If you plan to run one config across several machines, these are the two rules I would hold to from the start.</p>
        <p class="leading-relaxed">1. A feature module never refers to a host by name. It does not know which    hosts import it. 2. A host config never edits another host's file to get something done.</p>
        <p class="leading-relaxed">With those two in place, adding a capability to the laptop means writing one module and importing it in one place. The server's config is not involved, so a change meant for one machine cannot quietly land on another.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="card card-link p-5" aria-label="Previous part">
            <span class="eyebrow block">Part 6</span>
            <span class="mt-1 block font-display font-semibold leading-snug">Running the model myself</span>
          </a>
          <a href="https://bouallegui.pages.dev/posts/my-shell-is-a-package.html" class="card card-link p-5 text-right" aria-label="Next part">
            <span class="eyebrow block">Part 8</span>
            <span class="mt-1 block font-display font-semibold leading-snug">My shell is a package</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/one-file-one-capability.html" class="font-semibold text-link hover:text-ocean-dark">One file, one capability: how I stopped maintaining an import list</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/secrets-with-keys-i-already-had.html" class="font-semibold text-link hover:text-ocean-dark">Secrets, kept with keys I already had</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 7 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#nix-monorepo" class="font-semibold text-link hover:text-ocean-dark">One flake for every machine I run</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>An editor built up from nothing, on purpose</title>
      <link>https://bouallegui.pages.dev/posts/an-editor-built-up-from-nothing.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/an-editor-built-up-from-nothing.html</guid>
      <pubDate>Sun, 31 May 2026 12:00:00 GMT</pubDate>
      <description>Building Neovim from an empty init.lua: lazy loading, a colourscheme, a tree-sitter grammar for my own file formats, and nixd pointed at the flake.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">A Neovim distribution would have been faster. LazyVim or NvChad would have had highlighting and LSP working in an afternoon. I started from an empty <code class="font-mono text-sm text-ocean-dark">init.lua</code> instead and wrote the plugins, colourscheme, keymaps and LSP wiring myself. It took longer, and the payoff shows up when something breaks: I know what every line in the config is for.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Loading plugins on demand</h2>
        <p class="leading-relaxed">Every plugin declares its own load trigger through <a href="https://github.com/nvim-neorocks/lz.n" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark"><code class="font-mono text-sm text-ocean-dark">lz.n</code></a>: a keypress, a filetype, a command, or eagerly if it genuinely has to be.</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">return {
  "nvim-treesitter",
  event = "DeferredUIEnter",
}

return {
  "fastaction.nvim",
  keys = { { "&lt;leader&gt;a", function() require("fastaction").code_action() end } },
}</code></pre>
        <p class="leading-relaxed">Nothing pays a startup cost until something asks for it. Each entry in the plugin list says when it loads, so the startup time is something you can reason about instead of measure and shrug at.</p>
        <p class="leading-relaxed">If you are starting from scratch, set this up early. Retrofitting lazy loading onto a config with thirty eagerly loaded plugins means working out, one at a time, what each plugin actually needs to be loaded for.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">A colourscheme and a grammar</h2>
        <p class="leading-relaxed">The colourscheme (<code class="font-mono text-sm text-ocean-dark">gxvjbox</code>) is a gruvbox-derived palette I wrote from scratch: a set of hex values, applied to each highlight group by hand. The same base16 table is used outside Neovim, for the terminal, the file manager and the bar, so those tools agree on colours without anyone copying values between five config formats.</p>
        <p class="leading-relaxed">The less common piece is tree-sitter support for a couple of personal file formats I use for my own project files. I wrote the grammar: a compiled C parser, syntax queries, and a <a href="https://topiary.tweag.io/" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">topiary</a> formatting query, all in <code class="font-mono text-sm text-ocean-dark">neovimConfig/</code> next to the rest of the editor config. A distribution cannot ship that, because the formats only exist for me.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Pointing the language server at this flake</h2>
        <p class="leading-relaxed">The Nix language server here is not only configured for Nix syntax. It is pointed at this flake's own evaluated option set, so completion on a NixOS option comes from this repository's module tree rather than from generic nixpkgs documentation:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">options.nixos.expr = '(builtins.getFlake (toString ./.)).nixosConfigurations.amal.options'</code></pre>
        <p class="leading-relaxed">Swap <code class="font-mono text-sm text-ocean-dark">amal</code> for your own <code class="font-mono text-sm text-ocean-dark">nixosConfigurations</code> name. Once that line is correct, writing Nix inside Neovim gives you completions for your own custom modules, which is the main reason I keep the editor config in the same repo as the system config.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/posts/a-private-cloud-with-a-guest-list.html" class="card card-link p-5" aria-label="Previous part">
            <span class="eyebrow block">Part 4</span>
            <span class="mt-1 block font-display font-semibold leading-snug">A private cloud, sized for the people who actually use it</span>
          </a>
          <a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="card card-link p-5 text-right" aria-label="Next part">
            <span class="eyebrow block">Part 6</span>
            <span class="mt-1 block font-display font-semibold leading-snug">Running the model myself</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/my-shell-is-a-package.html" class="font-semibold text-link hover:text-ocean-dark">My shell is a package</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/a-desktop-that-scrolls.html" class="font-semibold text-link hover:text-ocean-dark">A desktop that scrolls instead of stacking</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 5 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#nix-monorepo" class="font-semibold text-link hover:text-ocean-dark">One flake for every machine I run</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>A private cloud, sized for the people who actually use it</title>
      <link>https://bouallegui.pages.dev/posts/a-private-cloud-with-a-guest-list.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/a-private-cloud-with-a-guest-list.html</guid>
      <pubDate>Sun, 10 May 2026 12:00:00 GMT</pubDate>
      <description>Immich, Jellyfin, Grocy and SearXNG on a home server, with nothing reachable from the internet. How the tailnet-only setup is put together.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">There is a middle option between using a big cloud provider for everything and self-hosting nothing: run the services yourself, but only make them reachable by the handful of devices and people you would hand a key to. That is what the home server, which I will call aleph, is for.</p>
        <p class="leading-relaxed"><a href="https://immich.app/" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">Immich</a> backs up photos the way Google Photos would, except the photos stay on a disk I own. <a href="https://jellyfin.org/" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">Jellyfin</a> is the media server, <a href="https://grocy.info/" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">Grocy</a> tracks household inventory, and <a href="https://docs.searxng.org/" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">SearXNG</a> is a meta-search engine that keeps no logs.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Nothing is exposed to the internet, on purpose</h2>
        <p class="leading-relaxed">None of these services open a port to the outside world. They sit behind <a href="https://tailscale.com/" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">Tailscale</a> and are reachable only from devices that have been added to the tailnet:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">networking.firewall.trustedInterfaces = [ "tailscale0" ];</code></pre>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">services.jellyfin = {
  enable = true;
  openFirewall = false;  # no WAN exposure, reachable via tailnet only
};</code></pre>
        <p class="leading-relaxed">If you are choosing between this and a reverse proxy with TLS and a domain, they are different jobs. A public service has to defend against the entire internet, permanently. A tailnet-only service only has to be correct for a known, small, authenticated set of devices, and the unauthenticated part of the attack surface never exists. No port scanner finds anything, there is no certificate to renew, and there is no login page to brute-force because there is no login page reachable from outside the tailnet at all.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">What it costs</h2>
        <p class="leading-relaxed">Nobody outside the tailnet can use any of it without being added first. For this setup that is the point. These are not products. They are infrastructure for a small group of people who already trust each other, running on hardware one of us owns and maintains. The way a new person gets in is to ask to be added, which is about the right amount of friction for something shared more like a house than like a service.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The order I would do it in</h2>
        <p class="leading-relaxed">If you are starting from a fresh NixOS install, three steps in this order save some time.</p>
        <p class="leading-relaxed">1. Bring up Tailscale first and prove the tailnet works before configuring    any service. <code class="font-mono text-sm text-ocean-dark">networking.firewall.trustedInterfaces = [ "tailscale0" ];</code>    is what makes services listening on that interface reachable. Without it    every service looks broken and the firewall is the reason. 2. Leave <code class="font-mono text-sm text-ocean-dark">openFirewall = false</code> on everything. If a service is unreachable    from inside the tailnet too, then the service is the problem, and that is    a much shorter debug than ruling out the firewall. 3. Only add a domain and ACME when you genuinely need a public hostname. One    of my services runs with <code class="font-mono text-sm text-ocean-dark">enableSSL = false</code> because its configured    hostname does not resolve publicly, and the certificate request fails in    a way that is confusing until you read the error properly.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/posts/secrets-with-keys-i-already-had.html" class="card card-link p-5" aria-label="Previous part">
            <span class="eyebrow block">Part 3</span>
            <span class="mt-1 block font-display font-semibold leading-snug">Secrets, kept with keys I already had</span>
          </a>
          <a href="https://bouallegui.pages.dev/posts/an-editor-built-up-from-nothing.html" class="card card-link p-5 text-right" aria-label="Next part">
            <span class="eyebrow block">Part 5</span>
            <span class="mt-1 block font-display font-semibold leading-snug">An editor built up from nothing, on purpose</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="font-semibold text-link hover:text-ocean-dark">Running the model myself</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/one-file-one-capability.html" class="font-semibold text-link hover:text-ocean-dark">One file, one capability: how I stopped maintaining an import list</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 4 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#self-hosted" class="font-semibold text-link hover:text-ocean-dark">Self-hosted server infrastructure</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>Secrets, kept with keys I already had</title>
      <link>https://bouallegui.pages.dev/posts/secrets-with-keys-i-already-had.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/secrets-with-keys-i-already-had.html</guid>
      <pubDate>Sun, 19 Apr 2026 12:00:00 GMT</pubDate>
      <description>Committing secrets in the open, encrypted with age, using keys derived by ssh-to-age from SSH keys you already have.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">A public config still contains real secrets somewhere: API keys, service passwords, things that cannot be public. The standard advice is to keep them out of git, in a separate vault, behind a separate login. I put them in the repo instead, encrypted, and used keys I already had.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Encrypted in place</h2>
        <p class="leading-relaxed"><a href="https://github.com/Mic92/sops-nix" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">sops-nix</a> encrypts a YAML file with <a href="https://github.com/FiloSottile/age" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">age</a> and lets NixOS decrypt it declaratively at activation time. The encrypted file sits in the repo:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">samba-share-password: ENC[AES256_GCM,data:Woc7Szy...,type:str]
sops:
  age:
    - recipient: age1u2wj…
      enc: |
        -----BEGIN AGE ENCRYPTED FILE-----
        ...</code></pre>
        <p class="leading-relaxed">That file is safe to commit and useless without the matching private key. The part worth copying is where the key comes from. I did not generate a new keypair for this job. <a href="https://github.com/Mic92/ssh-to-age" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark"><code class="font-mono text-sm text-ocean-dark">ssh-to-age</code></a> derives an age identity from an SSH key that already exists, either my own key or a machine's host key:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">nix run nixpkgs#ssh-to-age &lt; /etc/ssh/ssh_host_ed25519_key.pub
# age18gr7…</code></pre>
        <p class="leading-relaxed">That public key goes into <code class="font-mono text-sm text-ocean-dark">.sops.yaml</code> as a recipient. The machine then decrypts its own secrets at boot using the SSH host key it already had for completely unrelated reasons. There is no second identity to generate, rotate, or lose track of.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Who is allowed to read them</h2>
        <p class="leading-relaxed">Because the keys come from SSH identities, the recipient list for secrets matches a list I was already keeping: which machines and which personal keys I trust. Adding a machine is three steps. Convert its host key, add it as a recipient, run <code class="font-mono text-sm text-ocean-dark">sops updatekeys</code>. Those are the same three steps for the first secret and the fiftieth.</p>
        <p class="leading-relaxed">The rest of the config refers to placeholders:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">sops.templates.credentials.content = ''
  password=${config.sops.placeholder."samba-share-password"}
'';</code></pre>
        <p class="leading-relaxed"><code class="font-mono text-sm text-ocean-dark">nix eval</code> on that template prints the literal string <code class="font-mono text-sm text-ocean-dark">&lt;SOPS:...:PLACEHOLDER&gt;</code>, never the value. The secret appears decrypted only at activation, directly into <code class="font-mono text-sm text-ocean-dark">/run/secrets/</code>. It does not go into the Nix store, it is not baked into a build artifact, and it does not show up in <code class="font-mono text-sm text-ocean-dark">nix derivation show</code>.</p>
        <p class="leading-relaxed">Two things to settle before relying on any of this. Keep your own age private key somewhere you can still read it after a machine is gone, because losing that key means losing the secrets with it. And remember that <code class="font-mono text-sm text-ocean-dark">.sops.yaml</code> is only as trustworthy as the public keys inside it, so removing a retired machine's key is part of retiring the machine.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/posts/a-desktop-that-scrolls.html" class="card card-link p-5" aria-label="Previous part">
            <span class="eyebrow block">Part 2</span>
            <span class="mt-1 block font-display font-semibold leading-snug">A desktop that scrolls instead of stacking</span>
          </a>
          <a href="https://bouallegui.pages.dev/posts/a-private-cloud-with-a-guest-list.html" class="card card-link p-5 text-right" aria-label="Next part">
            <span class="eyebrow block">Part 4</span>
            <span class="mt-1 block font-display font-semibold leading-snug">A private cloud, sized for the people who actually use it</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/running-the-model-myself.html" class="font-semibold text-link hover:text-ocean-dark">Running the model myself</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/one-flake-three-machines.html" class="font-semibold text-link hover:text-ocean-dark">One flake, three machines that barely resemble each other</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 3 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#nix-monorepo" class="font-semibold text-link hover:text-ocean-dark">One flake for every machine I run</a> · <a href="https://bouallegui.pages.dev/portfolio.html#self-hosted" class="font-semibold text-link hover:text-ocean-dark">Self-hosted server infrastructure</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>A desktop that scrolls instead of stacking</title>
      <link>https://bouallegui.pages.dev/posts/a-desktop-that-scrolls.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/a-desktop-that-scrolls.html</guid>
      <pubDate>Sun, 29 Mar 2026 12:00:00 GMT</pubDate>
      <description>niri with noctalia and a colourscheme, packaged so the whole desktop builds as one thing, and menus that show the keybinds instead of asking you to remember them.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">Most desktops ask you to manage windows: resize them, tile them, alt-tab through a stack of them. <a href="https://github.com/YaLTeR/niri" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">niri</a> does not have a stack. Windows sit in columns on a strip that scrolls sideways, and you move along the strip instead of arranging a grid. It took me about a week to stop reaching for the old habits. After that, a stacking window manager felt slower to me, mostly because I kept losing track of where the windows went.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The desktop is one package</h2>
        <p class="leading-relaxed">niri, the bar, the launcher and the colours are assembled through <a href="https://github.com/BirdeeHub/nix-wrapper-modules" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark"><code class="font-mono text-sm text-ocean-dark">nix-wrapper-modules</code></a> into a single derivation:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">packages.desktop = inputs.wrapper-modules.wrappers.niri.wrap {
  inherit pkgs;
  imports = [ self.wrappersModules.niri ];
  terminal = lib.getExe self'.packages.terminal;
  env.EDITOR = lib.getExe self'.packages.neovim;
};</code></pre>
        <p class="leading-relaxed">The practical part is that installing my desktop is one package and updating it is one rebuild. If the compositor config and the panel config disagree, the build fails, and that is a better place to find out than the login screen.</p>
        <p class="leading-relaxed"><a href="https://github.com/noctalia-dev/noctalia-shell" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">noctalia-shell</a> is the bar, launcher and control centre on top of it. It uses a gruvbox palette that is defined once in the Nix config and passed to the terminal, the editor, the file manager and the bar. One definition is the difference between a theme and five themes that slowly stop matching.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Keybinds you can look up</h2>
        <p class="leading-relaxed">A keybind-driven setup only works if you can remember the keybinds, and I could not. What fixed it for me was the <code class="font-mono text-sm text-ocean-dark">which-key.nvim</code> pattern: press the leader, pause, and a menu lists the available continuations with their descriptions, taken from the <code class="font-mono text-sm text-ocean-dark">desc</code> strings the real keybinds already carry. There is no cheat sheet to maintain, because the menu is generated from the bindings themselves.</p>
        <p class="leading-relaxed">On the niri side I do the same with a small launcher (<code class="font-mono text-sm text-ocean-dark">self.mkWhichKeyExe</code>) bound to <code class="font-mono text-sm text-ocean-dark">Mod+G</code>: Bluetooth, Wi-Fi, a browser, Discord, a volume mixer. If you are setting up niri, add that lookup early. It decides whether the setup is usable by someone who has not read your config, or only by you.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/posts/one-file-one-capability.html" class="card card-link p-5" aria-label="Previous part">
            <span class="eyebrow block">Part 1</span>
            <span class="mt-1 block font-display font-semibold leading-snug">One file, one capability: how I stopped maintaining an import list</span>
          </a>
          <a href="https://bouallegui.pages.dev/posts/secrets-with-keys-i-already-had.html" class="card card-link p-5 text-right" aria-label="Next part">
            <span class="eyebrow block">Part 3</span>
            <span class="mt-1 block font-display font-semibold leading-snug">Secrets, kept with keys I already had</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/my-shell-is-a-package.html" class="font-semibold text-link hover:text-ocean-dark">My shell is a package</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/an-editor-built-up-from-nothing.html" class="font-semibold text-link hover:text-ocean-dark">An editor built up from nothing, on purpose</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 2 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#nix-monorepo" class="font-semibold text-link hover:text-ocean-dark">One flake for every machine I run</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>One file, one capability: how I stopped maintaining an import list</title>
      <link>https://bouallegui.pages.dev/posts/one-file-one-capability.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/one-file-one-capability.html</guid>
      <pubDate>Sun, 08 Mar 2026 12:00:00 GMT</pubDate>
      <description>How the config is laid out. import-tree reads every file in modules/, so there is no import list to keep current, and one convention holds the rest together.</description>
      <content:encoded><![CDATA[<div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">A NixOS config usually grows the same artifact: a long <code class="font-mono text-sm text-ocean-dark">imports = [ ... ]</code> list near the top of the flake, kept current by hand, usually a little behind the directory it points at. Add a module, forget the list, and you spend twenty minutes wondering why the option you just wrote does nothing.</p>
        <p class="leading-relaxed">I got tired of that. My flake has no import list, and the whole output is one line:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);</code></pre>
        <p class="leading-relaxed"><a href="https://github.com/vic/import-tree" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark"><code class="font-mono text-sm text-ocean-dark">import-tree</code></a> reads <code class="font-mono text-sm text-ocean-dark">modules/</code> recursively and imports every <code class="font-mono text-sm text-ocean-dark">*.nix</code> file it finds as a <a href="https://flake.parts/" target="_blank" rel="noopener noreferrer" class="font-semibold text-link hover:text-ocean-dark">flake-parts</a> module. Put a file in the directory and it is used. Take it out and it is not. The directory is the list.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">One convention, no framework</h2>
        <p class="leading-relaxed">Auto-discovery only stays readable if the files it finds look alike. The rule I keep is small: one file, one <code class="font-mono text-sm text-ocean-dark">flake.nixosModules.&lt;name&gt;</code>.</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed"># modules/features/tailscale.nix
{ self, inputs, ... }:
{
  flake.nixosModules.tailscale = { config, ... }: {
    services.tailscale.enable = true;
    networking.firewall.trustedInterfaces = [ "tailscale0" ];
  };
}</code></pre>
        <p class="leading-relaxed">A host does not need to know what is inside <code class="font-mono text-sm text-ocean-dark">tailscale.nix</code>. It asks for the capability by name:</p>
        <pre class="rounded-card border border-slate-200 bg-slate-50 p-5 text-sm overflow-x-auto"><code class="font-mono text-xs leading-relaxed">imports = [
  self.nixosModules.tailscale
  self.nixosModules.nvidia
  self.nixosModules.desktop
];</code></pre>
        <p class="leading-relaxed">That rule pays for itself in two ordinary ways. "What is on this machine" is answered by reading the imports block of one host file, instead of chasing conditionals through the tree. And adding a capability to one host does not touch the others, because the only thing they share is the module name.</p>
        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Three directories, three jobs</h2>
        <ul class="space-y-2 pl-5 list-disc marker:text-link">
          <li class="leading-relaxed"><strong><code class="font-mono text-sm text-ocean-dark">base/</code></strong>: settings every machine needs anyway. Nix options, garbage collection, secrets wiring. Not optional, and not something a host chooses.</li>
          <li class="leading-relaxed"><strong><code class="font-mono text-sm text-ocean-dark">features/</code></strong>: capabilities that do not care which machine they run on. A search engine, a game launcher, a text editor config, a Minecraft server.</li>
          <li class="leading-relaxed"><strong><code class="font-mono text-sm text-ocean-dark">hosts/&lt;Name&gt;/</code></strong>: one directory per machine. It picks the features that machine wants and adds the hardware detail (<code class="font-mono text-sm text-ocean-dark">hardware.nix</code>) that cannot be shared, since no two machines have the same disks.</li>
        </ul>
        <p class="leading-relaxed">Three machines share almost all of <code class="font-mono text-sm text-ocean-dark">features/</code>: a laptop, an older laptop I keep for conservation, and a headless server. They differ where the hardware or the role forces it, and nowhere else.</p>
        <p class="leading-relaxed">If you are setting something like this up, the split I would fix early is <code class="font-mono text-sm text-ocean-dark">base</code> / <code class="font-mono text-sm text-ocean-dark">features</code> / <code class="font-mono text-sm text-ocean-dark">hosts</code>. The rest of the tree gets reorganised a few times anyway, and this part is annoying to change once six files depend on how it is named.</p>
        <nav class="mt-10 grid gap-4 sm:grid-cols-2" aria-label="More from this series">
          <a href="https://bouallegui.pages.dev/blog.html" class="card card-link p-5 text-right">
            <span class="eyebrow block">The series</span>
            <span class="mt-1 block font-display font-semibold leading-snug">All eight parts</span>
          </a>
          <a href="https://bouallegui.pages.dev/posts/a-desktop-that-scrolls.html" class="card card-link p-5 text-right" aria-label="Next part">
            <span class="eyebrow block">Part 2</span>
            <span class="mt-1 block font-display font-semibold leading-snug">A desktop that scrolls instead of stacking</span>
          </a>
        </nav>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/one-flake-three-machines.html" class="font-semibold text-link hover:text-ocean-dark">One flake, three machines that barely resemble each other</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/secrets-with-keys-i-already-had.html" class="font-semibold text-link hover:text-ocean-dark">Secrets, kept with keys I already had</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">Part 1 of 8 of <a href="https://bouallegui.pages.dev/blog.html" class="font-semibold text-link hover:text-ocean-dark">NixOS, in pieces</a>.</p>
        </div>

        <p class="text-sm text-slate-500">Where this lives: <a href="https://bouallegui.pages.dev/portfolio.html#nix-monorepo" class="font-semibold text-link hover:text-ocean-dark">One flake for every machine I run</a></p>
      </div>]]></content:encoded>
    </item>
    <item>
      <title>What an AWS account looks like from the inside</title>
      <link>https://bouallegui.pages.dev/posts/an-aws-account-from-the-inside.html</link>
      <guid isPermaLink="true">https://bouallegui.pages.dev/posts/an-aws-account-from-the-inside.html</guid>
      <pubDate>Sun, 16 Nov 2025 12:00:00 GMT</pubDate>
      <description>Everything I run lives on hardware I own, so I spent time in an AWS account to see how the same jobs look when someone else owns the machine: security groups, key pairs, instance roles, and what keeps billing after you stop the instance.</description>
      <content:encoded><![CDATA[<figure class="container mt-10 px-5 md:px-8">
        <img src="https://bouallegui.pages.dev/images/projects/aws.svg" alt="Diagram: on top, a path from a laptop through a security group to an EC2 instance running nginx on port 80 in eu-west-3, the Paris region. Below, three tiles for what keeps existing after the instance is stopped: the EBS volume, the public IPv4 address, and the instance itself, which is the only one of the three that stops being billed." width="640" height="360" loading="lazy" decoding="async" class="w-full max-w-3xl rounded-xl shadow-soft mx-auto" data-lightbox="img">
        <figcaption class="mt-2 text-center text-sm text-slate-500">The part of the setup that stuck in my head, and the part that keeps costing after you stop the machine.</figcaption>
      </figure>

      <div class="container mx-auto mt-10 grid max-w-3xl gap-8 px-5 text-slate-700 md:px-8">
        <p class="leading-relaxed">Everything I run runs on hardware I can point at: my laptop, the retired laptop I keep for conservation, and a headless machine I call aleph. I had never operated inside a cloud account, and that gap bothered me, because most of what I read about security and infrastructure assumes the reader knows what an account looks like from the inside. So in September 2025 I opened one, put a single EC2 instance on the free tier, and spent time moving through it.</p>
        <p class="leading-relaxed">This is written about a year later, so it is a description of how the platform behaves rather than a log of what I typed. The parts worth keeping are the ones that changed how I think about the machines I already run, and those are what this note is about.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The free study guide as a map</h2>
        <p class="leading-relaxed">I began with the <a href="https://docs.aws.amazon.com/pdfs/aws-certification/latest/cloud-practitioner-02/cloud-practitioner-02.pdf" class="font-semibold text-link hover:text-ocean-dark">CLF-C02 exam guide</a>, which is a free PDF and the closest thing AWS publishes to a table of contents for the platform. It divides the content into four domains: Cloud Concepts at 24 percent, then Security and Compliance, Cloud Technology, and Cloud Financial Management. As a map it is excellent. As teaching it is weak, because almost every line is a noun phrase. Region, availability zone, edge location, security group, key pair, AMI, instance profile, launch template.</p>
        <p class="leading-relaxed">Reading those words was easy and almost none of them stayed, because they named operations I already performed under other names. So I launched an instance, to have one thing the nouns could point at.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The account, and the free tier</h2>
        <p class="leading-relaxed">The free tier moved just before I got there. The <a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-free-tier-usage.html" class="font-semibold text-link hover:text-ocean-dark">EC2 free tier usage page</a> draws the line at 15 July 2025. An account opened before that date gets 750 hours a month of <code class="font-mono text-sm text-ocean-dark">t2.micro</code> or <code class="font-mono text-sm text-ocean-dark">t3.micro</code> for the first twelve months. An account opened after it gets credits instead, $100 at signup with up to $100 more for working through the foundational services, and a six month Free Plan, with no EC2 hour allowance in it. I opened mine in September 2025, so it is on the second model, and the difference is one you feel once you are inside: a quota tells you what is free, credits tell you how much of the normal price you have left.</p>
        <p class="leading-relaxed">One instance in <code class="font-mono text-sm text-ocean-dark">eu-west-3</code>, the Paris region, on an Ubuntu image, at a free tier size. I did most of the moving around from the command line, because <code class="font-mono text-sm text-ocean-dark">aws ec2 describe-instances</code> was faster than finding the right page in the console.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The security group sits outside the machine</h2>
        <p class="leading-relaxed">This is where the platform and my habits diverged most, and it is the part I found most interesting. On my own machines the firewall runs on the box. I can list the rules, change them over ssh, and if I get it wrong there is still a local console to fix it from. A security group is attached to the network interface instead of to the operating system, and a packet that matches no inbound rule is dropped before the instance ever sees it. It is stateful, so one inbound rule is enough for the reply traffic to come back; there is no outbound rule to write for a response.</p>
        <p class="leading-relaxed">The consequence is a debugging distinction I did not have before:</p>
        <ul class="space-y-2 pl-5 list-disc marker:text-link">
          <li class="leading-relaxed"><span class="font-semibold text-ocean-dark">Connection refused</span> means the packet reached the machine and no process was listening on that port. The security group did its job and the machine answered.</li>
          <li class="leading-relaxed"><span class="font-semibold text-ocean-dark">A hang that ends in a timeout</span> means the group dropped the packet. Nothing on the instance is involved, so nothing in its logs will tell you why.</li>
        </ul>
        <p class="leading-relaxed">The other difference is what happens when you lock yourself out of SSH. There is no console behind an EC2 instance. That gap is what AWS Systems Manager Session Manager is for: it opens a shell through the EC2 API using an instance role, so the recovery path is a second API rather than a keyboard plugged into the machine. I would rather have a console I can reach with a cable, and I would still put the instance behind a tailnet the way I put aleph behind one, but the API recovery path is a reasonable answer to a problem my own machines do not have.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">The key pair, and what the image decides</h2>
        <p class="leading-relaxed">The console generates the private key and hands it to you once. There is no way to read it back later, only to swap the key pair on a stopped instance and reboot. The username you log in as is decided by the image rather than by you: <code class="font-mono text-sm text-ocean-dark">ec2-user</code> on Amazon Linux, <code class="font-mono text-sm text-ocean-dark">ubuntu</code> on the Ubuntu images, and something else again on the rest. Mine was an Ubuntu image, so the user was <code class="font-mono text-sm text-ocean-dark">ubuntu</code>, and that was the one point in the whole exercise where the cloud and my own machines looked like the same job.</p>
        <p class="leading-relaxed">When the instance went away I deleted the key with it. A private key for a machine that no longer exists is just a file waiting to be found, and the platform has no way to hand one back, which is the right design even though it makes the whole setup impossible to resurrect.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">A web server, and then IAM</h2>
        <p class="leading-relaxed">I put nginx on it, listening on port 80 with the security group open to the whole internet, and served a site of mine from it rather than the default page. That was the part I kept turning over: an address in Paris that a browser anywhere could reach, on a machine whose entire existence was a handful of API calls. It also moved the audit question somewhere I was not used to looking. The part that decides what reaches the listener is not on the machine, so <code class="font-mono text-sm text-ocean-dark">ss -tlnp</code> and a ruleset dump are only half the answer.</p>
        <p class="leading-relaxed">IAM is the part of the account the guide weights most and the part that has no clean equivalent in anything I run at home. The idea worth taking away is the difference between a user with a long lived access key and a role attached to an instance. A role hands the instance temporary credentials through the metadata service, so there is no secret to write into a file on a machine that is not yours, and nothing to rotate when the machine is replaced.</p>
        <p class="leading-relaxed">The failure mode the Security and Compliance domain keeps circling is an identity with more room than the job needs, attached to something nobody is thinking about any more. On a platform where one policy reaches across every resource in the account, that is easy to arrive at by accident. My own habit is least privilege on a single box, and I would write the policy before the instance next time.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">Stop, then terminate</h2>
        <p class="leading-relaxed">A stopped instance is not billed for instance hours, but the EBS volume attached to it is billed for as long as it exists, and so is a public IPv4 address. Since 1 February 2024 AWS charges <a href="https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/" class="font-semibold text-link hover:text-ocean-dark">$0.005 per hour for every public IPv4 address</a>, attached to something or not, with 750 hours a month of it included in the older free tier. The <a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-lifecycle.html" class="font-semibold text-link hover:text-ocean-dark">instance lifecycle page</a> says it in one sentence: some resources, EBS volumes and Elastic IP addresses among them, incur charges regardless of the instance state.</p>
        <p class="leading-relaxed">So <code class="font-mono text-sm text-ocean-dark">stop-instances</code> is the command that looks like turning a machine off and is not, and <code class="font-mono text-sm text-ocean-dark">terminate-instances</code> is the one that releases the address and by default deletes the root volume. I stopped it, then terminated it, which is the order that actually leaves nothing behind. On the model my account is on, those charges come out of the credits rather than out of a quota, so they are easy to miss: nothing arrives as money, and the volume and the address are still there.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">CloudTrail, which I do not have at home</h2>
        <p class="leading-relaxed">One thing in the account is genuinely ahead of what I run at home, and it is the thing I went back and thought about most: every action in an AWS account is an API call, and CloudTrail records API calls. So the account has a history of decisions by default, not a history of file contents.</p>
        <p class="leading-relaxed">On aleph and on my laptop, a change committed to the flake leaves the file and its git history, which is good provenance for configuration. A change made by hand in a shell leaves nothing behind but shell history, and a change made by a compromised process leaves less than that. An audit log of who called what, with an identity attached to each call, is a thing my machines do not have and should.</p>

        <h2 class="mt-8 text-2xl font-display font-semibold text-ocean-dark">My machines and the cloud, side by side</h2>
        <p class="leading-relaxed">The instance did most of the teaching, because it turned each noun in the guide into something I had touched. The mapping onto what I run at home is not exact, but it is close enough to be useful.</p>
        <div class="overflow-x-auto">
          <table class="w-full text-sm">
            <thead>
              <tr class="text-left border-b border-slate-200"><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">AWS</th><th class="py-2 pr-4 font-display font-semibold text-ocean-dark">On my own machines</th></tr>
            </thead>
            <tbody class="text-slate-600">
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">security group</td><td class="py-2 pr-4">the firewall on aleph, with the trust placed in front of the machine instead of on it</td></tr>
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">AMI</td><td class="py-2 pr-4">a disk image built from a config, which is what a NixOS image is</td></tr>
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">key pair</td><td class="py-2 pr-4">the ssh keys I already keep, except AWS holds the public half and gives you the private one once</td></tr>
              <tr class="border-b border-slate-100"><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">instance role</td><td class="py-2 pr-4">a capability handed to a process instead of a secret handed to a human</td></tr>
              <tr><td class="py-2 pr-4 font-mono text-xs whitespace-nowrap">CloudTrail</td><td class="py-2 pr-4">an audit log of who called what, which is the thing my own machines do not have</td></tr>
            </tbody>
          </table>
        </div>

        <div class="rounded-card border border-ocean/20 bg-ocean/5 p-5 text-sm">
          <p class="font-semibold text-ocean-dark mb-1">If you are going to do the same exercise</p>
          <ul class="space-y-1 text-slate-600">
            <li>• Give the instance a job before you launch it. Serving something of your own over port 80 is enough, and it turns the security group from a form field into a decision.</li>
            <li>• Write the notes while the thing is running. The reasoning is the first part to go, and it is the part worth keeping.</li>
            <li>• Read the free tier page for the month you sign up rather than the month you read about it. AWS changed the model in July 2025 and the two versions are not comparable.</li>
            <li>• When you are finished, terminate and then check the volumes and the addresses. Stopping leaves both behind.</li>
            <li>• Start with the exam guide even if you have no interest in the certificate. It is the only free document that lists the whole platform in one place.</li>
          </ul>
        </div>

        <h2 class="mt-2 text-2xl font-display font-semibold text-ocean-dark">Further reading</h2>
        <ul class="space-y-2">
          <li><a href="https://docs.aws.amazon.com/pdfs/aws-certification/latest/cloud-practitioner-02/cloud-practitioner-02.pdf" class="font-semibold text-link hover:text-ocean-dark">AWS Certified Cloud Practitioner exam guide (CLF-C02)</a>: the four domains and what each one expects you to know.</li>
          <li><a href="https://aws.amazon.com/free/free-tier-faqs/" class="font-semibold text-link hover:text-ocean-dark">AWS Free Tier FAQs</a>: the July 2025 change from the twelve month model to credits.</li>
          <li><a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-lifecycle.html" class="font-semibold text-link hover:text-ocean-dark">Amazon EC2 instance state changes</a>: which states are billed, and which resources are billed anyway.</li>
          <li><a href="https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/" class="font-semibold text-link hover:text-ocean-dark">The public IPv4 address charge</a>: the announcement, the rate, and the free tier exception.</li>
        </ul>

        <div class="mt-6 rounded-card border border-ocean/20 bg-ocean/5 p-5">
          <p class="font-semibold text-ocean-dark mb-2">Keep reading</p>
          <ul class="space-y-1 text-sm text-slate-600">
            <li><a href="https://bouallegui.pages.dev/posts/a-private-cloud-with-a-guest-list.html" class="font-semibold text-link hover:text-ocean-dark">A private cloud, sized for the people who actually use it</a></li>
            <li><a href="https://bouallegui.pages.dev/posts/one-flake-three-machines.html" class="font-semibold text-link hover:text-ocean-dark">One flake, three machines that barely resemble each other</a></li>
          </ul>
          <p class="mt-3 text-sm text-slate-500">No repository behind this one. The instance was a learning exercise, and it is terminated.</p>
        </div>
      </div>]]></content:encoded>
    </item>
  </channel>
</rss>
