<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Projects on bismigel</title><link>https://bismigel.dev/projects/</link><description>Recent content in Projects on bismigel</description><generator>Hugo</generator><language>en-us</language><copyright>bismigel</copyright><lastBuildDate>Sat, 17 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://bismigel.dev/projects/index.xml" rel="self" type="application/rss+xml"/><item><title>The Bakuran homelab: technical reference</title><link>https://bismigel.dev/projects/bakuran-homelab/</link><pubDate>Sat, 17 Oct 2026 00:00:00 +0000</pubDate><guid>https://bismigel.dev/projects/bakuran-homelab/</guid><description>A definitive, versioned write-up of the lab: hardware, network, storage, GitOps, services, backups, security and the failure modes that shaped it.</description><content:encoded><![CDATA[<h1 id="document-control">Document control</h1>
<table>
	<thead>
			<tr>
					<th>Field</th>
					<th>Value</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Document</td>
					<td>Bakuran homelab: technical reference</td>
			</tr>
			<tr>
					<td>Version</td>
					<td><strong>2026-10-20</strong> (Redis HA and Cilium correction)</td>
			</tr>
			<tr>
					<td>Scope</td>
					<td>Hardware, network edge, cluster, storage, delivery, services, observability, backups, security</td>
			</tr>
			<tr>
					<td>Audience</td>
					<td>Future me, anyone reviewing the design, and the agent that helps run it</td>
			</tr>
			<tr>
					<td>Redactions</td>
					<td>Internal addresses, account identifiers and credentials are omitted on purpose</td>
			</tr>
	</tbody>
</table>
<p>This is the reference document: the thing I wish existed when I started. It describes <strong>what</strong> runs, <strong>why</strong> it is shaped that way, and <strong>how</strong> it fails. Where a component has a sharp edge, the edge is written down.</p>
<h1 id="0-why-bakuran">0. Why &ldquo;Bakuran&rdquo;</h1>
<p>In Tagalog, <strong>bakuran</strong> is the yard or enclosure around a house, from <strong>bakod</strong>, a fence. It is the space behind the house where things get built, repaired, and occasionally taken apart to see how they work.</p>
<p>That is exactly what this is: a fenced-off corner of the house for experiments. It is personal, small, and safe to break on purpose. The name is a reminder that this is a backyard, not a data centre, useful precisely because it is allowed to be a little messy, as long as the messy parts are understood and written down.</p>
<h1 id="1-goals-and-constraints">1. Goals and constraints</h1>
<p>The lab exists to be <strong><strong>useful</strong></strong> and to be a place to practise operations on real failures. The constraints shaped almost every decision:</p>
<ul>
<li><strong>Three old machines, no rack, residential power and internet.</strong> High availability is desirable, but high <strong>cost</strong> is not.</li>
<li><strong>One operator.</strong> Anything that needs a human at 3am must page; anything that needs a human every week must be automated or removed.</li>
<li><strong>Everything must be rebuildable.</strong> A machine can die and the lab should come back from declarative state plus backups.</li>
<li><strong>The edge is the only door.</strong> No inbound ports; Cloudflare Tunnel is the sole path from the internet.</li>
</ul>
<h1 id="2-hardware">2. Hardware</h1>
<table>
	<thead>
			<tr>
					<th>Role</th>
					<th>Machine</th>
					<th>Specs</th>
					<th>Runs</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Platform</td>
					<td>AMD desktop</td>
					<td>Ryzen 5 4600G (6c/12t), 16 to 32 GB RAM, 2× NVMe + 2× SATA</td>
					<td>Control plane + etcd, ArgoCD, ingress, edge connectors</td>
			</tr>
			<tr>
					<td>Media</td>
					<td>Intel mini-PC</td>
					<td>i5-7500T (4c), QuickSync, 2× NVMe + 2× SATA</td>
					<td>Jellyfin and the *arr stack, transcoding</td>
			</tr>
			<tr>
					<td>Quorum + photos</td>
					<td>2012 MacBook Pro</td>
					<td>~240 GB SSD</td>
					<td>Third etcd member (tainted), Immich server/DB</td>
			</tr>
			<tr>
					<td>Admin</td>
					<td>Workstation</td>
					<td>-</td>
					<td>NixOS + Home Manager, deploys, dead-man&rsquo;s switch</td>
			</tr>
			<tr>
					<td>Edge</td>
					<td>Cloudflare</td>
					<td>-</td>
					<td>Tunnel, DNS, TLS, Zero Trust Access, R2</td>
			</tr>
	</tbody>
</table>
<pre class="mermaid" id="mermaid-0"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-0"] = "flowchart TB\n  CF[\"Cloudflare edge\u003cbr/\u003eDNS · TLS · Access · Tunnel\"]\n  subgraph P[\"Platform node: Ryzen 5 4600G\"]\n    K1[\"k3s server · etcd\"]\n    A[\"ArgoCD\"]\n    I[\"ingress-nginx\"]\n    O[\"Prometheus · Alertmanager · Grafana\"]\n  end\n  subgraph M[\"Media node: i5-7500T\"]\n    K2[\"k3s server · etcd\"]\n    J[\"Jellyfin\"]\n    R[\"Sonarr · Radarr · Prowlarr · Bazarr\"]\n    D[\"SABnzbd · qBittorrent\"]\n    B[\"Kavita · LazyLibrarian\"]\n  end\n  subgraph Q[\"Quorum + photos: MacBook Pro\"]\n    K3[\"k3s server · etcd (tainted)\"]\n    IM[\"Immich server · Postgres\"]\n  end\n  W[\"Admin workstation\u003cbr/\u003edeploy · dead-man's switch\"]\n  R2[(\"Cloudflare R2\")]\n  CF --\u003e I\n  I --\u003e A\n  I --\u003e J\n  I --\u003e IM\n  I --\u003e B\n  K1 --- K2\n  K2 --- K3\n  D --\u003e R\n  R --\u003e J\n  B --\u003e J\n  W --\u003e R2";</script>

<p>The taint on the MacBook is deliberate: it is a quorum member and a photo host, not a general-purpose worker. Removing it from the cluster would leave two etcd members, a worse failure mode than using it carefully.</p>
<h1 id="3-network-and-edge">3. Network and edge</h1>
<p>The LAN is flat and boring. Every host is static; there is no VLAN segmentation. That is a known trade-off: simplicity now, a segmentation project later.</p>
<p>The <strong><strong>public edge is Cloudflare Tunnel</strong></strong>, not port forwarding:</p>
<ul>
<li>One tunnel, two connectors (platform + media) for redundancy.</li>
<li>Every public hostname is a proxied CNAME to the tunnel; the tunnel routes to ingress-nginx bound to <strong><strong>loopback-only</strong></strong> NodePorts. There is no externally reachable NodePort.</li>
<li>TLS is terminated at Cloudflare; the origins speak HTTP inside the cluster.</li>
<li><strong>Zero Trust Access</strong> gates admin and personal apps with email OTP and per-app session lengths (short for admin, long for media devices).</li>
<li><strong><strong>Public exceptions</strong></strong> are deliberate: Jellyfin, Immich, Karakeep and Kavita have their own authentication, and their native clients cannot complete an interactive Access challenge. They are exposed behind the user&rsquo;s own login instead. This is the single biggest security trade-off in the design and it is documented rather than hidden.</li>
<li>Minimum TLS 1.2 and Bot Fight Mode are enforced from the same declarative edge file that creates the tunnel routes, DNS records and Access apps. A daily timer reconciles it so hand edits do not survive.</li>
</ul>
<pre class="mermaid" id="mermaid-1"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-1"] = "flowchart LR\n  U[\"Phones · laptops · TVs\"] --\u003e CF[\"Cloudflare\u003cbr/\u003eTLS 1.2+ · Bot Fight Mode\"]\n  CF --\u003e|\"Access-gated hostnames\"| ACC[\"Zero Trust Access\u003cbr/\u003eemail OTP\"]\n  CF --\u003e|\"public hostnames\"| T[\"Tunnel · 2 connectors\"]\n  ACC --\u003e T\n  T --\u003e ING[\"ingress-nginx\u003cbr/\u003eloopback-only NodePorts\"]\n  ING --\u003e ADM[\"Admin \u0026 media UIs\"]\n  ING --\u003e PUB[\"Jellyfin · Immich · Karakeep · Kavita\"]";</script>

<h1 id="4-the-cluster">4. The cluster</h1>
<p>One <strong><strong>k3s cluster with three embedded-etcd members</strong></strong>, not three clusters. This is the most important simplification in the lab: one API server, one ArgoCD, one set of CRDs, one place to look.</p>
<ul>
<li>Kubernetes v1.36.5+k3s1, containerd, Cilium VXLAN with kube-proxy.</li>
<li>Three server nodes; the MacBook is tainted so only Immich and the Redis HA replicas opt in with explicit tolerations.</li>
<li>Workloads are pinned where their hardware lives (media on the Intel box for QuickSync and local storage; Immich on the MacBook; platform pieces anywhere).</li>
<li>Ingress runs with two replicas; cloudflared connectors run on two nodes.</li>
<li>NodePorts are loopback-only by design; the LAN path exists only for the media UIs (Jellyfin/*arrs) because those apps refuse no-auth remote access but are comfortable on the LAN.</li>
</ul>
<h1 id="5-storage">5. Storage</h1>
<p>There are three storage stories, and confusing them is how data gets lost.</p>
<p><strong><strong>Cluster storage (Longhorn).</strong></strong> Application config and databases live on Longhorn volumes with two replicas. This is for state that must follow a pod to another node: ArgoCD, monitoring, app configs, small databases.</p>
<p><strong><strong>Media pool (mergerfs).</strong></strong> Four disks across two hosts are unioned into a single ~1.2 TB pool. The platform host exports its partitions over NFS; the media host unions all four. Media is re-downloadable and <strong>not</strong> backed up. This is explicit: the backup budget goes to configuration, credentials and photos, not to Linux ISOs and TV episodes.</p>
<p><strong><strong>Photo store (local).</strong></strong> Immich keeps its library and Postgres data on the MacBook&rsquo;s internal SSD. It is backed up by a database dump plus a file-level archive, not by replicating the disk.</p>
<h1 id="6-gitops-and-delivery">6. GitOps and delivery</h1>
<p>Everything that can be declared is declared. The flow is:</p>
<ol>
<li>An application repo merges to <code>main</code>.</li>
<li>GitHub Actions builds a container image and pushes it to GHCR.</li>
<li>The GitOps repo references the image; ArgoCD (auto-sync, self-heal, prune) applies it.</li>
<li>Cluster state drift is corrected automatically, and an alert fires if an app stays out of sync or unhealthy.</li>
</ol>
<pre class="mermaid" id="mermaid-2"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-2"] = "flowchart TB\n  CH[\"Change\"] --\u003e PR[\"Pull request\"]\n  PR --\u003e|\"review + merge\"| APP[\"App repo main\"]\n  APP --\u003e CI[\"GitHub Actions\u003cbr/\u003etest · build · push\"]\n  CI --\u003e IMG[\"GHCR image\u003cbr/\u003eimmutable tag\"]\n  IMG --\u003e GIT[\"GitOps repo\u003cbr/\u003emanifests + image tag\"]\n  GIT --\u003e ARGO[\"ArgoCD\u003cbr/\u003eauto-sync · self-heal · prune\"]\n  ARGO --\u003e K8S[\"k3s cluster\"]\n\n  INFRA[\"bakuran-infra\u003cbr/\u003eNixOS + edge config\"] --\u003e DEP[\"deploy-rs\u003cbr/\u003emagic rollback\"]\n  DEP --\u003e HOSTS[\"Hosts\u003cbr/\u003ekernel · services · cloudflared\"]";</script>

<p>NixOS hosts are separate: they are built from <code>bakuran-infra</code> and deployed with <code>deploy-rs</code>, with magic rollback. The OS is not managed by ArgoCD, and that is intentional: the cluster should not be able to brick its own hosts.</p>
<p>Reproducibility rules that came out of real failures:</p>
<ul>
<li>Images are <strong><strong>pinned</strong></strong> when upstream publishes a stable version or digest. Prometheus, Grafana, KSM, node-exporter, Alertmanager, Karakeep, Immich, Kavita and qBittorrent are on fixed versions or digests. The LinuxServer *arr stack and a few other app images track <code>latest</code> or <code>develop</code>; that is a known reproducibility gap.</li>
<li>Renovate proposes updates for the pinned set; a human reviews and merges.</li>
<li>Direct pushes to <code>main</code> are blocked by a git hook on the admin workstation. Public repos also use branch protection; private repos on the free plan cannot, which is a known limitation.</li>
<li>Rebuild steps that involve application UIs are scripted. The book stack has a single idempotent bootstrap command that recreates admin accounts, libraries and download clients.</li>
</ul>
<h1 id="7-platform-services">7. Platform services</h1>
<ul>
<li><strong>ingress-nginx</strong>: the only HTTP entry from the tunnel; forwards client IPs.</li>
<li><strong>cert-manager</strong>: internal certificates; public TLS is at Cloudflare.</li>
<li><strong>External Secrets Operator</strong>: Bitwarden Secrets Manager is the source of truth; Kubernetes secrets are materialised from it. Host-level secrets use SOPS/age.</li>
<li><strong>Monitoring</strong>: Prometheus, Alertmanager and Grafana. Scrapes node-exporter, kube-state-metrics, Kepler (power) and the ArgoCD controller endpoints.
<ul>
<li><strong>Kepler</strong>: per-node power/energy metrics; useful for capacity and for noticing a runaway transcode.</li>
<li><strong>ArgoCD</strong>: Helm release `argocd`, chart `argo-cd` 10.10.5. Redis HA runs three Redis/sentinel pods and three HAProxy pods. The per-pod announce Services are made headless by a Helm post-renderer, because the chart otherwise puts sentinel behind a ClusterIP.</li>
<li><strong>Hermes</strong>: in-cluster AI agent and SRE; read-only tools and guardrails.</li>
<li><strong>Homepage</strong>: a single landing page for every service.</li>
</ul>
</li>
</ul>
<h1 id="8-media-platform">8. Media platform</h1>
<p>Jellyfin and the *arr stack run on the Intel host, pinned by nodeSelector so transcoding uses QuickSync and media reads stay local.</p>
<ul>
<li>Sonarr (TV), Radarr (movies), Prowlarr (indexers), Bazarr (subtitles).</li>
<li>qBittorrent for torrents (anime is torrent-first: Nyaa batches), SABnzbd for Usenet fallback.</li>
<li>A host-level nftables kill switch forces all egress through ProtonVPN and blocks the internet if the tunnel drops. LAN and cluster traffic are exempt.</li>
<li>Remote Jellyfin clients are capped at 12 Mbps per user; LAN clients are not. Jellyfin trusts the ingress and cluster networks as proxies so it can tell the difference.</li>
<li>New media appears on a scheduled scan (mergerfs has no inotify semantics worth relying on).</li>
</ul>
<h1 id="9-photos">9. Photos</h1>
<p>Immich runs split: the server, Postgres and Valkey on the MacBook; the machine-learning container on the platform host. Photo backup from phones is the primary use, so the public hostname is not Access-gated; Immich&rsquo;s own accounts and app passwords are the control. Accounts mirror the family&rsquo;s Jellyfin users.</p>
<h1 id="10-books">10. Books</h1>
<p>Kavita (reading) and LazyLibrarian (acquisition) run on the media host, sharing <code>/mnt/media/books</code>.</p>
<pre class="mermaid" id="mermaid-3"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-3"] = "flowchart LR\n  LL[\"LazyLibrarian\"] --\u003e|search| NZB[\"NZBGeek\"]\n  NZB --\u003e|NZB| LL\n  LL --\u003e|send| SAB[\"SABnzbd\u003cbr/\u003ecategory: books\"]\n  SAB --\u003e COMP[\"/downloads/complete/books\"]\n  COMP --\u003e|post-process| LIB[\"/mnt/media/books\"]\n  LIB --\u003e KAV[\"Kavita\u003cbr/\u003escan + metadata\"]\n  KAV --\u003e RD[\"Reader (web/PWA)\"]";</script>

<p>The pipeline is: LazyLibrarian searches Newznab (NZBGeek) → sends to SABnzbd (category <code>books</code>) → post-processes completed downloads into <code>/books</code> → Kavita scans the folder.</p>
<p>Sharp edges that are now codified:</p>
<ul>
<li>LazyLibrarian&rsquo;s SAB client fields are not enough; the <strong>download method</strong> must be enabled or every grab is silently dropped.</li>
<li>The directory SAB reports back must exist inside LazyLibrarian, so both containers mount the same media tree.</li>
<li>New authors&rsquo; books default to <code>Skipped</code>; the setup sets <code>Wanted</code> so a new author is actually searched.</li>
<li>Kavita&rsquo;s library must have <strong><strong>metadata enabled</strong></strong>, otherwise it derives series from filenames (<code>Title - Author.epub</code> → no series) and imports nothing while logging &ldquo;Unable to parse any meaningful information&rdquo;.</li>
<li>LazyLibrarian re-serialises its config on startup and drops sections it did not create. Its configuration is therefore driven through its real UI by a script, not by editing files.</li>
<li>Kavita&rsquo;s account model needs the <code>Login</code> role; without it the account exists but login says &ldquo;account is disabled&rdquo;. Account lock/unlock toggles that role.</li>
</ul>
<p>The agent can acquire books through the media MCP: find an author, add them, inspect wanted/snatched books, and queue/search individual titles. The LazyLibrarian API is internal and keyed; the key is exactly 32 characters (the app rejects anything else, quietly).</p>
<h1 id="11-bookmarks">11. Bookmarks</h1>
<p>Karakeep stores bookmarks and full-text search runs in Meilisearch. AI tagging and summarisation use <strong><strong>local Ollama</strong></strong>: qwen3:8b for text and qwen3-vl:8b for images. No bookmark content leaves the LAN. The public hostname is not Access-gated because the mobile apps cannot do the OTP flow; NextAuth is the gate.</p>
<h1 id="12-the-agent-duwende">12. The agent (Duwende)</h1>
<p><strong>Why &ldquo;Duwende&rdquo;</strong></p>
<p>A duwende is a small household spirit in Philippine folklore, a little caretaker of the home and yard. The lab already has a bakuran, so naming its resident helper Duwende completes the metaphor. It is meant to be a helpful, slightly mischievous presence in the yard, not a product name.</p>
<p>The agent is an in-cluster AI SRE. The namespace and ArgoCD app are being renamed to `duwende`; the upstream workload and dashboard still use the Hermes name.</p>
<p>It runs as a Deployment with a 10Gi PVC at `/opt/data` for config, sessions, memory and cron. It uses DeepSeek `deepseek-chat`. The dashboard is `agent.bismigel.dev` behind Cloudflare Access and basic auth; Telegram uses the same bot with a single-user allowlist.</p>
<p>Tools:</p>
<ul>
<li>Kubernetes MCP with the `homelab-agent-read` ClusterRole: get, list and watch, with no Secrets.</li>
<li>Prometheus MCP for queries.</li>
<li>media MCP for the *arr and book stack: add, search and queue, with no delete tools.</li>
<li>GitHub MCP plus `gh` with a repo-scoped token: branches and pull requests.</li>
<li>Cloudflare reads through a GET-only proxy; writes return 403.</li>
</ul>
<p>The context docs and `SOUL.md` are on the PVC. The daily 08:00 America/Toronto report goes to Telegram. The agent&rsquo;s state is included in the appdata backup.</p>
<p>Guardrails:</p>
<ul>
<li>Kubernetes is read-only.</li>
<li>Cloudflare writes are blocked outside the agent.</li>
<li>The PR-only rule is a working rule, not a hard block; every branch gets review.</li>
<li>Destructive work needs human approval, and secrets must not be written to logs.</li>
<li>The agent runs inside the cluster, so it shares the failure domain. The external dead-man&rsquo;s switch and Alertmanager remain the primary path.</li>
</ul>
<p>The design is described in the blog post <a href="https://bismigel.dev/posts/2026-10-18-hermes-homelab-sre-agent/">Hermes as a homelab SRE</a>
.</p>
<h1 id="13-observability-and-alerting">13. Observability and alerting</h1>
<p>Prometheus scrapes the cluster and the edge paths that matter. Alerts route to Telegram through Alertmanager, grouped so a single failure does not become ten messages.</p>
<p>What is watched today:</p>
<ul>
<li>Node and target availability, disk fill, memory pressure.</li>
<li>ArgoCD application drift and health (after 15 minutes).</li>
<li>Logins to the edge and to Jellyfin/Immich (new sessions, not every request).</li>
<li>Backups: unit failure, staleness (newest archive older than 36 hours), and a weekly <strong><strong>restore verification</strong></strong> (decrypt, <code>gzip -t</code>, full <code>tar -t</code>, SQLite integrity check on the app databases, <code>gzip -t</code> on the database dump).</li>
<li>An external dead-man&rsquo;s switch on the admin workstation: if the public status endpoint is unreachable three checks in a row, Telegram gets a message; a recovery message follows.</li>
</ul>
<p>The dead-man&rsquo;s switch runs outside the cluster on purpose. A monitor that lives inside the thing it monitors is decoration.</p>
<h1 id="14-backups-and-disaster-recovery">14. Backups and disaster recovery</h1>
<p>Two daily, age-encrypted streams go to Cloudflare R2:</p>
<ul>
<li><code>credentials/</code>: host SSH keys (also the SOPS age identity), kubeconfig and the Bitwarden machine token. Kept 90 days.</li>
<li><code>appdata/</code>: config volumes and databases for the *arrs, Jellyfin, Grafana, Prometheus, Hermes, Immich (dump + library), Kavita, LazyLibrarian, Karakeep and Meilisearch. Kept 14 days.</li>
</ul>
<pre class="mermaid" id="mermaid-4"></pre>
<script>(window.__mermaidSources ||= {})["mermaid-4"] = "flowchart TB\n  A[\"App configs + databases\"] --\u003e D1[\"Daily appdata archive\"]\n  C[\"Host keys · kubeconfig · BWS token\"] --\u003e D2[\"Daily credentials archive\"]\n  D1 --\u003e E[\"age encryption\"]\n  D2 --\u003e E\n  E --\u003e R2[(\"Cloudflare R2\")]\n  R2 --\u003e L[\"Lifecycle\u003cbr/\u003e14d appdata · 90d credentials\"]\n  R2 --\u003e V[\"Weekly restore verification\u003cbr/\u003etar · SQLite integrity · gzip\"]\n  R2 --\u003e F[\"Daily freshness check\u003cbr/\u003eolder than 36h = stale\"]\n  V --\u003e TG[\"Telegram via Alertmanager\"]\n  F --\u003e TG\n  X[\"External dead-man's switch\"] --\u003e TG";</script>

<p>Design decisions:</p>
<ul>
<li>A partial backup <strong><strong>fails the unit</strong></strong> instead of uploading something incomplete.</li>
<li>Freshness is checked independently of the backup timer.</li>
<li>Restore is verified weekly, not assumed.</li>
<li>Media files are not backed up; they are re-downloadable. Photos are backed up because they are not.</li>
<li>The age identity needed to restore is itself stored in the password manager and on the admin workstation.</li>
</ul>
<p>The honest RPO is about a day for application state and credentials; the RTO is a rebuild plus restore, measured in hours, not minutes.</p>
<h1 id="15-security-posture">15. Security posture</h1>
<ul>
<li><strong>Secrets</strong> live in Bitwarden Secrets Manager, materialised by External Secrets; host secrets use SOPS/age. Nothing sensitive is in git.</li>
<li><strong>Edge</strong>: no inbound ports, TLS 1.2 minimum, Bot Fight Mode, Access on admin surfaces.</li>
<li><strong>Auth</strong>: each public app uses its own login; accounts are non-admin by default; lockout is enabled.</li>
<li><strong>Network</strong>: ingress-only default deny in the app namespaces. Same namespace and ingress-nginx are allowed; cross-namespace rules are explicit. Egress is still open. Jellyfin keeps a LAN-only 8096 exception for TVs.</li>
<li><strong>Supply chain</strong>: pinned where practical, a weekly image scan, dependency updates via Renovate, and a human merge gate. The *arr stack, some app images and utility images are not pinned yet.</li>
<li><strong>Change control</strong>: pull requests only; the cluster self-heals; host changes go through <code>deploy-rs</code>.</li>
</ul>
<p>Known gaps, stated plainly: no custom rate limiting on the free Cloudflare plan, no image signing or SBOM yet, no full-cluster disaster-recovery rehearsal yet, and one public login surface (Kavita) that is internet-facing.</p>
<h1 id="16-reproducibility">16. Reproducibility</h1>
<p>A rebuild has three layers:</p>
<ol>
<li><strong>NixOS hosts</strong>: <code>bakuran-infra</code>, deployed with <code>deploy-rs</code>; SSH keys, users, services and the edge reconciler are declared.</li>
<li><strong>Cluster</strong>: <code>bakuran-gitops</code> in ArgoCD; every namespace, workload, secret reference, ingress and alert is a manifest.</li>
<li><strong>Application state</strong>: restorable from the R2 backup, or recreated by scripts: the book stack has one idempotent bootstrap; the media stack has setup scripts for root folders, clients, indexers and users.</li>
</ol>
<p>Pinned images where upstream allows, versioned scripts and dated documentation are what make the next rebuild boring. That is the goal.</p>
<h1 id="17-failure-modes-that-shaped-the-design">17. Failure modes that shaped the design</h1>
<table>
	<thead>
			<tr>
					<th>Symptom</th>
					<th>Root cause</th>
					<th>Fix / rule</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Books downloaded but never imported</td>
					<td>SAB completed into a path LazyLibrarian did not mount</td>
					<td>Mount the same tree in both containers</td>
			</tr>
			<tr>
					<td>Every grab silently dropped</td>
					<td>SAB download method disabled</td>
					<td>Enable it in the UI setup script</td>
			</tr>
			<tr>
					<td>Kavita imported nothing</td>
					<td>Library metadata disabled; series parsed from filenames</td>
					<td>Enable metadata on the library</td>
			</tr>
			<tr>
					<td>&ldquo;account is disabled&rdquo; on login</td>
					<td>Missing <code>Login</code> role</td>
					<td>Role is part of the invite flow; lock/unlock toggles it</td>
			</tr>
			<tr>
					<td>Backup &ldquo;succeeded&rdquo; with missing data</td>
					<td>Failed dumps only logged SKIP</td>
					<td>Fail the unit before upload</td>
			</tr>
			<tr>
					<td>No backup alert when the timer stopped</td>
					<td>OnFailure cannot see a timer that never ran</td>
					<td>Daily freshness check + external dead-man&rsquo;s switch</td>
			</tr>
			<tr>
					<td>Backup never restored</td>
					<td>Trust</td>
					<td>Weekly automated restore verification</td>
			</tr>
			<tr>
					<td>Drift alerts never fired</td>
					<td>Scraped the ArgoCD Service; only the leader has app metrics</td>
					<td>Scrape controller endpoints</td>
			</tr>
			<tr>
					<td>Config rejected after restart</td>
					<td>Manual YAML indentation</td>
					<td>Validate configs before merge; prefer generated files</td>
			</tr>
			<tr>
					<td>Image pull failures after pinning</td>
					<td>Guessed tag (<code>v0.33.2</code> vs <code>0.33.2</code>)</td>
					<td>Verify tags/digests before merging</td>
			</tr>
	</tbody>
</table>
<h1 id="18-known-limitations-and-next">18. Known limitations and next</h1>
<ul>
<li>Branch protection is on for public repos; private repos on the free plan still rely on the workstation hook.
<ul>
<li>HSTS is enforced at Cloudflare. Rate limiting and MFA on Access are limited by the free plan and the identity provider.</li>
<li>Zero-touch rebuild is not complete: bootstrap is one command, not zero.</li>
<li>SLOs cover the public probes and error budgets; internal services still rely on symptom-based alerts.</li>
</ul>
</li>
<li>No secrets rotation schedule.</li>
<li>Media is unbacked by design; the trade-off is documented, not solved.</li>
<li>Full DR rehearsal (rebuild a host, restore everything) is still a manual plan.</li>
</ul>
<h1 id="19-version-history">19. Version history</h1>
<table>
	<thead>
			<tr>
					<th>Version</th>
					<th>Date</th>
					<th>Change</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>2026-10-20</td>
					<td>2026-10-20</td>
					<td>Redis HA corrected to the Helm chart post-renderer path; Cilium VXLAN, Kubernetes version, HSTS and SLO status updated</td>
			</tr>
			<tr>
					<td>2026-10-18</td>
					<td>2026-10-18</td>
					<td>Hermes SRE details; current edge monitoring and dashboards; image-pinning status clarified</td>
			</tr>
			<tr>
					<td>2026-10-17</td>
					<td>2026-10-17</td>
					<td>First definitive version: full architecture, backups/verification, security posture, failure modes</td>
			</tr>
	</tbody>
</table>
<p>If you are reading this in the future and something here is wrong, the document is the bug: update it in the same change that fixes the lab.</p>
]]></content:encoded></item><item><title>multi-ghostel</title><link>https://bismigel.dev/projects/multi-ghostel/</link><pubDate>Thu, 15 Oct 2026 00:00:00 +0000</pubDate><guid>https://bismigel.dev/projects/multi-ghostel/</guid><description>Directory-scoped terminal reuse and a toggleable dedicated window for ghostel in Emacs.</description><content:encoded><![CDATA[<p><code>multi-ghostel</code> is a small Emacs package that ports the parts of <a href="https://github.com/suonlight/multi-vterm" target="_blank" rel="noopener noreferrer">multi-vterm</a>
 I actually use to <a href="https://github.com/dakra/ghostel" target="_blank" rel="noopener noreferrer">ghostel</a>
. Most of multi-vterm already ships with ghostel, so this only adds what is missing.</p>
<h1 id="what-it-adds">What it adds</h1>
<ul>
<li><strong>Directory-scoped reuse</strong>: <code>multi-ghostel-cwd</code> keys off <code>default-directory</code> (following <code>cd</code> via OSC 7), so each directory gets one reusable terminal.</li>
<li><strong>An always-new command</strong>: <code>multi-ghostel</code> starts a fresh session, matching multi-vterm&rsquo;s behaviour instead of reusing ghostel&rsquo;s buffer.</li>
<li><strong>A dedicated window</strong>: toggle, open, close and focus a single terminal window without killing the shell running in it.</li>
</ul>
<h1 id="why-a-separate-package">Why a separate package</h1>
<p>ghostel is a vterm alternative built on the same idea (a real terminal inside Emacs). Rather than fork either project, this package composes the existing ghostel primitives with the multi-vterm workflow: one command to reuse a terminal per project, one to force a new one, and one to get a scratch terminal out of the way and back.</p>
<h1 id="install">Install</h1>
<p>Emacs 29 ships <code>package-vc-install</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">commonlisp</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <pre tabindex="0"><code class="language-commonlisp" data-lang="commonlisp">(package-vc-install &#34;https://github.com/bismigel/multi-ghostel&#34;)</code></pre>
</div>
<p>Or with <code>use-package</code>:</p>
<div class="code-block">
  <div class="code-block-header">
    <span class="code-block-lang">commonlisp</span>
    <button type="button" class="code-copy" aria-label="Copy code">
      <span class="code-copy-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</span>
      <span class="code-copy-icon code-copy-icon-check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</span>
      <span class="code-copy-label">Copy</span>
    </button>
  </div>
  <pre tabindex="0"><code class="language-commonlisp" data-lang="commonlisp">(use-package multi-ghostel
  :vc (:url &#34;https://github.com/bismigel/multi-ghostel&#34;)
  :bind ((&#34;C-c t&#34; . multi-ghostel-cwd)
         (&#34;C-c T&#34; . multi-ghostel)
         (&#34;C-`&#34;   . multi-ghostel-dedicated-toggle)))</code></pre>
</div>
<p>The source and suggested keybindings live on <a href="https://github.com/bismigel/multi-ghostel" target="_blank" rel="noopener noreferrer">GitHub</a>
.</p>
]]></content:encoded></item><item><title>Ate Luningning</title><link>https://bismigel.dev/projects/ateluningning/</link><pubDate>Thu, 08 Jan 2026 00:00:00 +0000</pubDate><guid>https://bismigel.dev/projects/ateluningning/</guid><description>A friendly Filipino auntie Discord bot with DeepSeek-powered chat, sessions and small everyday utilities.</description><content:encoded><![CDATA[<p><strong>Ate Luningning</strong> is a Discord bot written in Go: a warm, slightly nosy Filipino auntie who answers questions, keeps a conversation going for a while, and helps with the small things.</p>
<h1 id="what-she-does">What she does</h1>
<ul>
<li><code>!askluningning &lt;question&gt;</code>: one-shot AI answer (DeepSeek).</li>
<li><code>!chismis</code>: start a 30-minute session-based chat. After that you just talk; no prefix needed. <code>!yokona</code> ends it early.</li>
<li>Everyday helpers: <code>!hello</code>, <code>!ping</code>, <code>!uptime</code>, <code>!verse</code>, <code>!horoscope</code>, <code>!calc</code>, <code>!choose</code>, <code>!roll</code>.</li>
<li>Admin: <code>!lock</code> / <code>!unlock</code> a command per channel.</li>
</ul>
<h1 id="design-notes">Design notes</h1>
<p>The interesting part is not the AI call; it is the session model. A &ldquo;chismis&rdquo; session keeps recent context in memory, expires after 30 minutes of inactivity, and is scoped to the channel so two conversations do not bleed into each other. Long answers are handled explicitly: short replies go as one message, medium ones are split with rate-limit awareness, and very long ones are truncated with a note.</p>
<p>State lives in memory: the bot is deliberately restartable and stateless on disk. Configuration comes from environment variables (bot token, DeepSeek key), never from the repository.</p>
<p>Code: <a href="https://github.com/bismigel/ateluningning" target="_blank" rel="noopener noreferrer">github.com/bismigel/ateluningning</a>
.</p>
]]></content:encoded></item></channel></rss>