<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Christian Anderson</title>
    <description>The latest articles on DEV Community by Christian Anderson (@iam-tech).</description>
    <link>https://dev.to/iam-tech</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4110779%2Ff7ed549e-875c-4c07-8cb7-c1d3ff6a355e.jpg</url>
      <title>DEV Community: Christian Anderson</title>
      <link>https://dev.to/iam-tech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/iam-tech"/>
    <language>en</language>
    <item>
      <title>Set up spin-that-dice in 10 minutes, no Spotify app needed to start</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Mon, 02 Nov 2026 09:00:17 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-spin-that-dice-in-10-minutes-no-spotify-app-needed-to-start-4349</link>
      <guid>https://dev.to/iam-tech/set-up-spin-that-dice-in-10-minutes-no-spotify-app-needed-to-start-4349</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/casareanderson/spin-that-dice" rel="noopener noreferrer"&gt;spin-that-dice&lt;/a&gt; is a party dice for music: tap the die, get a random track from one of 43 categories, it plays. It now has a step-by-step guide that GitHub runs on a fresh machine every week, so if a step breaks I find out before you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can roll before you've set anything up
&lt;/h2&gt;

&lt;p&gt;A fresh clone ships a 305-track starter crate covering 10 of the 43 categories, so the dice works with no Spotify app at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/casareanderson/spin-that-dice &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;spin-that-dice
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
python3 spin.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;spin-that-dice on 127.0.0.1:8770 - spotify, 43 categories, 305 tracks indexed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;localhost:8770&lt;/code&gt; and tap. Playing the song needs Spotify Premium on that device. There's no free fallback since Spotify pulled 30-second previews in 2024.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj44rh279q58wef40v3bo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj44rh279q58wef40v3bo.png" alt="The weekly setup check: tests, first run, a roll from the crate, the static export" width="800" height="523"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's the real check. It starts the server, rolls from the crate over HTTP (Al Green, as it happens), checks an unknown category and a &lt;code&gt;../&lt;/code&gt; path are refused, and builds the static site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then fill the other 33 categories
&lt;/h2&gt;

&lt;p&gt;Make a free Spotify app, paste the client id and secret into &lt;code&gt;spin.env&lt;/code&gt;, restart. It tops up the thinnest categories in the background, at most 60 requests an hour. One thing the guide is loud about: &lt;strong&gt;Spotify's quota window re-arms while you're throttled.&lt;/strong&gt; Keep hammering after a 429 and the wait grows back towards 24 hours. spin.py stops calling until &lt;code&gt;Retry-After&lt;/code&gt; passes, so don't restart it in a loop.&lt;/p&gt;

&lt;p&gt;Or skip Spotify entirely and point it at Navidrome or Jellyfin with &lt;code&gt;SPIN_PROVIDER&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faumhg3oighh5tinfsvq1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faumhg3oighh5tinfsvq1.png" alt="spin-that-dice on a tablet" width="800" height="627"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full guide covers running it as a service, putting it on a tablet's home screen and publishing a static copy: &lt;a href="https://github.com/casareanderson/spin-that-dice/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;docs/SETUP.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What would you put on the dice? I'm always after better categories.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>opensource</category>
      <category>python</category>
      <category>github</category>
      <category>ai</category>
    </item>
    <item>
      <title>Set up setlisted in 15 minutes (and the bug writing the guide found)</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Sun, 01 Nov 2026 09:00:17 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-setlisted-in-15-minutes-and-the-bug-writing-the-guide-found-114d</link>
      <guid>https://dev.to/iam-tech/set-up-setlisted-in-15-minutes-and-the-bug-writing-the-guide-found-114d</guid>
      <description>&lt;p&gt;I wrote a proper setup guide for &lt;a href="https://github.com/casareanderson/setlisted" rel="noopener noreferrer"&gt;setlisted&lt;/a&gt;, my Spotify CLI for headless boxes, and made GitHub run the guide's steps on a fresh machine every week. The first run found a bug I'd shipped and never seen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug
&lt;/h2&gt;

&lt;p&gt;setlisted can read credentials from an optional secrets backend. I always have that backend, so I never ran it without one. On a fresh clone you don't, and the line that catches errors named the backend's error class. With the backend missing, that name doesn't exist, so instead of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: SPOTIFY_CLIENT_ID is not set. Put it in the environment or ~/.spotify-dj.env (see README).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;you got a Python traceback ending in &lt;code&gt;AttributeError: 'NoneType' object has no attribute 'SecretError'&lt;/code&gt;. Every single error, for every new user, looked like a crash. Fixed now, and the weekly check asserts the clean message, exit code 1 and no traceback.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the check proves
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ohtrf9pjg7mo4c5wcuj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2ohtrf9pjg7mo4c5wcuj.png" alt="The setup check passing: offline tests, every subcommand's help, and the clean missing-credential error" width="800" height="402"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It installs from a fresh clone, runs the offline sequencer tests, loads &lt;code&gt;--help&lt;/code&gt; for all 17 subcommands and checks that missing-credential error. It never talks to Spotify: no credentials in CI, and the quota is real.&lt;/p&gt;

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

&lt;p&gt;You need Python 3.10+, a Spotify account (Premium for playback) and a free Spotify app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/casareanderson/setlisted &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;setlisted
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
python3 tests/test_sequence.py      &lt;span class="c"&gt;# ALL OFFLINE TESTS PASS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the &lt;a href="https://developer.spotify.com/dashboard" rel="noopener noreferrer"&gt;Spotify dashboard&lt;/a&gt;, create an app, tick Web API and set the redirect URI to exactly &lt;code&gt;http://127.0.0.1:8888/callback&lt;/code&gt;. Put the client id and secret in &lt;code&gt;~/.spotify-dj.env&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 spotify_dj.py auth-url        &lt;span class="c"&gt;# open it, approve, copy the dead 127.0.0.1 URL&lt;/span&gt;
python3 spotify_dj.py auth-exchange &lt;span class="s2"&gt;"http://127.0.0.1:8888/callback?code=..."&lt;/span&gt;
python3 spotify_dj.py devices         &lt;span class="c"&gt;# success: your Spotify Connect devices&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkpjtrl5joo1vg5x0g3z1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkpjtrl5joo1vg5x0g3z1.png" alt="setlisted sequence --help output" width="760" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full guide, with every setting and every error message the code prints, is &lt;a href="https://github.com/casareanderson/setlisted/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;docs/SETUP.md&lt;/a&gt;. The one I'd read first: Spotify answers &lt;strong&gt;403, not 404&lt;/strong&gt;, for endpoints it removed in February 2026, so a renamed path looks like a permissions problem.&lt;/p&gt;

&lt;p&gt;Has a guide you wrote ever found a bug in your own code? It's a bit humbling tbh.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Set up toolsmith: an agent that researches tool swaps and can't run rm</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Sat, 31 Oct 2026 09:00:13 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-toolsmith-an-agent-that-researches-tool-swaps-and-cant-run-rm-1260</link>
      <guid>https://dev.to/iam-tech/set-up-toolsmith-an-agent-that-researches-tool-swaps-and-cant-run-rm-1260</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/casareanderson/toolsmith" rel="noopener noreferrer"&gt;toolsmith&lt;/a&gt; is the Hermes agent profile that looks at one area of my homelab each week, researches what could replace the tool I use there, tests the best candidate in a throwaway container, and asks me with ✅ / ❌ in Discord. Anything it can't back up with a measured fact gets withheld.&lt;/p&gt;

&lt;p&gt;It needs &lt;a href="https://github.com/NousResearch/hermes-agent" rel="noopener noreferrer"&gt;Hermes Agent&lt;/a&gt; and Proxmox to do the full job, so this isn't a five-minute toy. But the safety parts, the tests, the installer and the guard, run anywhere with Python. That's where the &lt;a href="https://github.com/casareanderson/toolsmith/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; starts, and those steps run on a fresh Ubuntu machine every week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1rt12uhqg95hvl2moqnd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1rt12uhqg95hvl2moqnd.png" alt="toolsmith's setup steps on a fresh GitHub runner: 13 tests pass, the installer runs, the guard blocks rm and allows a web search" width="799" height="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing the guide found a bug
&lt;/h2&gt;

&lt;p&gt;The README told you to run the tests and then &lt;code&gt;install.sh&lt;/code&gt;. Doing exactly that broke the install. The tests leave a &lt;code&gt;scripts/__pycache__&lt;/code&gt; folder, &lt;code&gt;install -m 0755 scripts/*&lt;/code&gt; refuses a directory, and &lt;code&gt;set -e&lt;/code&gt; turned that into a silent stop halfway through. No profile, no config, no data files.&lt;/p&gt;

&lt;p&gt;I clearly never ran the README in that order on a clean machine. A test that runs the guide did, the first time it ran. It's fixed now: the installer copies files only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and prove it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/casareanderson/toolsmith &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;toolsmith
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; tests       &lt;span class="c"&gt;# Ran 13 tests ... OK&lt;/span&gt;
&lt;span class="nv"&gt;HERMES_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.hermes ./install.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then poke the guard. It's a &lt;code&gt;pre_tool_call&lt;/code&gt; hook that sees every tool call before it happens, and it speaks JSON on stdin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;S&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.hermes/scripts
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"terminal","tool_input":{"command":"rm -rf /"}}'&lt;/span&gt; | python3 &lt;span class="nv"&gt;$S&lt;/span&gt;/toolsmith_guard.py
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"tool_name":"web_search","tool_input":{"query":"caddy vs traefik"}}'&lt;/span&gt; | python3 &lt;span class="nv"&gt;$S&lt;/span&gt;/toolsmith_guard.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first comes back &lt;code&gt;"decision": "block"&lt;/code&gt; with the list of what is allowed. The second comes back &lt;code&gt;{}&lt;/code&gt;, allowed. It's wired &lt;code&gt;fail_closed&lt;/code&gt;, so if the guard crashes, the call is blocked too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then wire it up
&lt;/h2&gt;

&lt;p&gt;Discord channel and your user id (the only ✅ that counts), your Proxmox nodes, which containers it may sandbox in, and the two timers. The bot token goes in an env file, never the config. &lt;code&gt;toolsmith_sandbox.sh refuse-test&lt;/code&gt; asks for too much memory on purpose and should be refused, and &lt;code&gt;toolsmith-review.sh --no-post&lt;/code&gt; does a full dry run with nothing sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;All of that wiring. It needs Discord, Proxmox and real containers, so the weekly run stops after the guard. The guide marks which is which.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/toolsmith/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/toolsmith/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Do you let your agents run shell commands at all? What's your guard?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>tutorial</category>
      <category>agents</category>
    </item>
    <item>
      <title>Set up ZimaBlade backups in 20 minutes, and practise first on any box</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:00:18 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-zimablade-backups-in-20-minutes-and-practise-first-on-any-box-i8k</link>
      <guid>https://dev.to/iam-tech/set-up-zimablade-backups-in-20-minutes-and-practise-first-on-any-box-i8k</guid>
      <description>&lt;p&gt;My &lt;a href="https://github.com/casareanderson/zimablade-recipes" rel="noopener noreferrer"&gt;ZimaBlade recipes&lt;/a&gt; are the stuff I learned running a ZimaBlade like a server instead of an appliance. The one most people need is backups, because the obvious way is broken in a way you don't notice.&lt;/p&gt;

&lt;p&gt;On ZimaOS your SSH user isn't root and can't sudo. Parts of &lt;code&gt;/DATA/AppData&lt;/code&gt; are root-only, mode 0700: your reverse proxy's certificates, tunnel tokens, auth databases. A plain &lt;code&gt;tar&lt;/code&gt; can't read them. It complains, exits 2, and &lt;strong&gt;still writes the archive&lt;/strong&gt;. You get a backup of a believable size with exactly the files you can't recreate missing from it.&lt;/p&gt;

&lt;p&gt;The new &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; lets you watch that happen, and the fix, on any Linux machine with Docker before you touch your real data. That practice run is tested on a fresh Ubuntu machine every week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15d268cpprdyj47wdw47.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15d268cpprdyj47wdw47.png" alt="The practice run on a fresh GitHub runner: plain tar fails on the root-only tls folder, the dry run lists it, the backup includes it, and verify proves it" width="799" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Practise on a throwaway folder
&lt;/h2&gt;

&lt;p&gt;The guide builds a fake AppData in &lt;code&gt;/tmp&lt;/code&gt; with one ordinary app and one root-only &lt;code&gt;tls&lt;/code&gt; folder, then points the scripts at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SRC_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/zima-practice/AppData &lt;span class="nv"&gt;DEST_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/zima-practice/Backups &lt;span class="nv"&gt;CONFIG_DIRS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/zima-practice/apps
&lt;span class="nb"&gt;tar&lt;/span&gt; &lt;span class="nt"&gt;-czf&lt;/span&gt; /tmp/plain.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /tmp/zima-practice AppData   &lt;span class="c"&gt;# Permission denied, exit 2, archive written anyway&lt;/span&gt;
bash scripts/backup-appdata.sh &lt;span class="nt"&gt;--dry-run&lt;/span&gt;                     &lt;span class="c"&gt;# lists the folder a plain tar would miss&lt;/span&gt;
bash scripts/backup-appdata.sh                               &lt;span class="c"&gt;# root via a privileged container: it gets in&lt;/span&gt;
bash scripts/verify-backup.sh                                &lt;span class="c"&gt;# proves it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line that matters is &lt;code&gt;OK — all 1 root-only director(ies) are in the archive&lt;/code&gt;. An ordinary tar couldn't have read that folder, so seeing it in the archive is the proof the backup worked as root.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then on the Zima
&lt;/h2&gt;

&lt;p&gt;Copy the two scripts over, dry run, back up, verify. The backup runs &lt;code&gt;docker run --privileged -v /:/host&lt;/code&gt;, which is root on the machine, so read the scripts first. &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/recipes/00-borrowing-root-from-docker.md" rel="noopener noreferrer"&gt;Recipe 00&lt;/a&gt; explains that one-liner bit by bit.&lt;/p&gt;

&lt;p&gt;ZimaOS doesn't run cron, so the schedule lives on another always-on box that SSHes in with &lt;code&gt;remote-trigger.sh&lt;/code&gt;. Nothing gets installed on the Zima.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three new recipes this week
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;03 Apps CasaOS can't eat.&lt;/strong&gt; My Homepage dashboard vanished from CasaOS twice. The second time the compose file I'd restored from was gone too. It runs as a plain &lt;code&gt;docker run&lt;/code&gt; now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;04 Uptime Kuma on the Zima.&lt;/strong&gt; Upgrading to the newer full image didn't remove the actively exploited Chromium bugs inside it. The &lt;code&gt;-slim&lt;/code&gt; image did, and it's 540 MB instead of 1.71 GB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;05 Disk pressure and a coldstore.&lt;/strong&gt; I nearly moved data to the Zima to free space elsewhere. The space was Docker images, which can't live on NFS anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;Anything on a real Zima. The weekly run proves the scripts' root trick and the verify checks on Ubuntu with Docker. ZimaOS differences (BusyBox &lt;code&gt;find&lt;/code&gt;, read-only root, no cron) are written up from my own box, not tested in CI.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When did you last actually restore something from your homelab backups?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>tutorial</category>
      <category>linux</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I rebuilt warden's console: 16 tabs down to 6 (and found my own DB world-readable)</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 29 Oct 2026 13:34:49 +0000</pubDate>
      <link>https://dev.to/iam-tech/i-rebuilt-wardens-console-16-tabs-down-to-6-and-found-my-own-db-world-readable-4kkb</link>
      <guid>https://dev.to/iam-tech/i-rebuilt-wardens-console-16-tabs-down-to-6-and-found-my-own-db-world-readable-4kkb</guid>
      <description>&lt;p&gt;warden is my homelab security console — it reads the logs my WAF can't see, scans the boxes, watches my AI agents, and proposes bans and patches I approve with a ✅ in Discord. It never does anything on its own. I &lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;wrote up what it does&lt;/a&gt; before; this is about the cleanup I just did, because it had got messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  It had grown 16 tabs
&lt;/h2&gt;

&lt;p&gt;Every time I added something — incidents, agent tracking, vulnerabilities, endpoint integrity — it got its own tab. Dashboards grow like that. One day I counted and it was &lt;strong&gt;16 tabs across four groups&lt;/strong&gt;, and I realised I was hunting for things I used every day.&lt;/p&gt;

&lt;p&gt;So I cut it down to &lt;strong&gt;6&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Overview&lt;/strong&gt; — the dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review&lt;/strong&gt; — the one place for everything waiting on my ✅ / ❌&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threats&lt;/strong&gt; — detections, edge, network, incidents, geo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents&lt;/strong&gt; — what my AI agents are reaching out to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Posture&lt;/strong&gt; — attack surface, vulnerabilities, endpoints, health&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Settings&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The related views sit under a little sub-nav inside each tab, so nothing's gone — it's just one click deeper. And instead of a scary number on every tab, a parent tab shows a small dot when something inside actually needs me. The real counts live where they make sense, so I don't get a meaningless "512" on a tab that's really just counting known CVEs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuu5e83nwcxb2522ibv02.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuu5e83nwcxb2522ibv02.png" alt="warden's new six-tab console on demo data" width="799" height="512"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The small things that were quietly annoying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The settings page listed raw config keys like &lt;code&gt;patch.expire_hours&lt;/code&gt; and &lt;code&gt;cloudflare.zone_id&lt;/code&gt;. Now each field has a plain label ("Approval expires after (hours)", "Cloudflare Zone ID") with the raw key dimmed underneath, so I still know what maps to the config file.&lt;/li&gt;
&lt;li&gt;API tokens and the "how do I connect a tool" bits were off on their own. They live under &lt;strong&gt;Account&lt;/strong&gt; now, where I'd actually look for them.&lt;/li&gt;
&lt;li&gt;The approvals queue used to sit on top of the Settings page. It's its own &lt;strong&gt;Review&lt;/strong&gt; tab now.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bit that mattered: my database was world-readable
&lt;/h2&gt;

&lt;p&gt;While I was in there I checked how warden stores anything sensitive. Most of it was already fine — API tokens are only kept as a SHA-256 hash, passwords are scrypt, session cookies are random and only their hash is stored, and actual secrets (bot tokens, API keys) never touch the database at all.&lt;/p&gt;

&lt;p&gt;Then I looked at the file permissions on the database itself. &lt;strong&gt;644. World-readable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On a shared box that's a real problem: the database holds logs, session hashes, and — if you use warden's own 2FA — the TOTP seed. Anything else running on that machine as another user could just read it. In my case that includes the sandboxed coding agent I let loose on my repos, which is exactly the kind of thing warden exists to keep honest.&lt;/p&gt;

&lt;p&gt;Fix was simple but I made it stick: warden now forces the database (and its write-ahead files) to &lt;code&gt;0600&lt;/code&gt; on every start, and each process sets a umask so anything it creates is owner-only from birth. I tested it by deliberately setting it back to 644 and restarting — it put itself straight back to 600.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: the dangerous gaps aren't the exotic ones. It was a default file permission sitting there the whole time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;It's &lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;, AGPL-3.0, dependency-free Python — it'll run on a Pi. The &lt;a href="https://github.com/casareanderson/warden/releases/tag/v3.2.0" rel="noopener noreferrer"&gt;v3.2 release&lt;/a&gt; has the full changelog, and there's a &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;5-minute demo setup&lt;/a&gt; if you want to click around before wiring it to anything.&lt;/p&gt;

&lt;p&gt;Still detect-only and read-only by default. It tells me what it'd do; I decide.&lt;/p&gt;

</description>
      <category>security</category>
      <category>opensource</category>
      <category>agents</category>
      <category>database</category>
    </item>
    <item>
      <title>Set up a GPU watch on a ZimaBlade: get told when the card falls off</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Wed, 28 Oct 2026 09:00:19 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-a-gpu-watch-on-a-zimablade-get-told-when-the-card-falls-off-54ob</link>
      <guid>https://dev.to/iam-tech/set-up-a-gpu-watch-on-a-zimablade-get-told-when-the-card-falls-off-54ob</guid>
      <description>&lt;p&gt;My ZimaBlade has a Quadro P600 on a powered riser for Immich. It works, for a while, then logs &lt;code&gt;Xid 79, GPU has fallen off the bus&lt;/code&gt; and stays gone until a reboot. The nasty part is Immich doesn't break. It quietly falls back to the CPU and carries on, slowly. Mine went six days before I noticed.&lt;/p&gt;

&lt;p&gt;So the &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich" rel="noopener noreferrer"&gt;GPU repo&lt;/a&gt; has a new script: &lt;code&gt;zima-gpu-watch.sh&lt;/code&gt;. It checks every few minutes, logs how long each boot's GPU lasts, and alerts once when it goes and once when it's back. Not every five minutes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdds3c9f7fxz7edzu30j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbdds3c9f7fxz7edzu30j.png" alt="The watcher run against the real ZimaBlade, whose GPU was off the bus at 408 hours of uptime, then the alive and dead check from the weekly setup run, alerting once per change" width="800" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That first line is my actual Zima this morning: dead, 408 hours into the boot. The rest is the check the &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup guide&lt;/a&gt; runs every week on a fresh machine, using a stand-in for &lt;code&gt;nvidia-smi&lt;/code&gt; so it can fake alive and dead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose first
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/casareanderson/zimablade-gpu-immich &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;zimablade-gpu-immich
bash diagnostics/zima-gpu-check.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine read-only sections. As a normal user, five are accurate and the rest tell you they need root (on ZimaOS that's the privileged-container route in the guide). Read section 04 first: &lt;code&gt;width 0&lt;/code&gt; is a physical fault no driver will fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then watch it
&lt;/h2&gt;

&lt;p&gt;From another always-on box with an SSH key to the Zima:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ZIMA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@zima &lt;span class="nv"&gt;GPU_MATCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;P600 &lt;span class="nv"&gt;WEBHOOK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://discord.com/api/webhooks/... bash diagnostics/zima-gpu-watch.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that in cron every 15 minutes. Two rules it follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unreachable isn't dead.&lt;/strong&gt; If SSH can't reach the Zima (rebooting, network blip) it logs &lt;code&gt;unreachable&lt;/code&gt; and doesn't touch the state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One alert per change.&lt;/strong&gt; First run just records the starting state.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;The real hardware. The weekly run proves both scripts run cleanly on a machine with no GPU and that the alive/dead logic alerts exactly once per change. Whether your card stays on the bus is between you, your riser cable and your grounds.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Anyone got a riser setup in a small box that's actually stable? What fixed it?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>linux</category>
      <category>tutorial</category>
      <category>ai</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Set up warden in 5 minutes: try my homelab security console on demo data</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:00:21 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-warden-in-5-minutes-try-my-homelab-security-console-on-demo-data-28bn</link>
      <guid>https://dev.to/iam-tech/set-up-warden-in-5-minutes-try-my-homelab-security-console-on-demo-data-28bn</guid>
      <description>&lt;p&gt;&lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;warden&lt;/a&gt; is my in-house security console: it reads the logs, scans the machines, and proposes bans and patches that I approve with a ✅ in Discord. It never does anything on its own.&lt;/p&gt;

&lt;p&gt;The usual problem with a tool like this is you have to wire it to everything before you can see if you even like it. So the setup guide starts with demo data. Five minutes, nothing to connect.&lt;/p&gt;

&lt;p&gt;The guide is tested too. A GitHub job pulls the commands out of &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;docs/SETUP.md&lt;/a&gt; and runs them on a fresh Ubuntu machine every week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cqsa0r6p8ua9tlv1qpz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cqsa0r6p8ua9tlv1qpz.png" alt="warden's demo steps on a fresh GitHub runner: demo data, a user, the sign-in redirect, the REST summary and the setup check" width="800" height="567"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;Linux with systemd and Python 3. Tested on Ubuntu 24.04 and Debian 13.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;python3-yaml python3-requests nmap
&lt;span class="nb"&gt;sudo &lt;/span&gt;git clone https://github.com/casareanderson/warden.git /opt/warden
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /opt/warden &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /opt/warden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Look before you wire anything
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;WARDEN_DATA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/warden-demo
python3 tools/demo.py
./warden-setup user add demo &lt;span class="nt"&gt;--role&lt;/span&gt; admin
python3 warden-ui.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;a href="http://127.0.0.1:8792" rel="noopener noreferrer"&gt;http://127.0.0.1:8792&lt;/a&gt; and sign in as &lt;code&gt;demo&lt;/code&gt; with the one-time password it printed. You'll be asked to pick your own.&lt;/p&gt;

&lt;p&gt;It also has a REST API and an MCP endpoint, so an AI agent can ask it "anything dodgy last night?". Mint a token and try it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;./warden-setup token try-it | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://127.0.0.1:8792/api/v1/summary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get JSON with &lt;code&gt;"mode": "detect-only"&lt;/code&gt; and the headline counts. Every MCP tool is read-only, and a test enforces that: an agent can ask, it can't ban or patch anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the real install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./warden-setup init                  &lt;span class="c"&gt;# asks about your estate, alerts and machines&lt;/span&gt;
./warden-setup check &lt;span class="nt"&gt;--hosts&lt;/span&gt;         &lt;span class="c"&gt;# ✓ / ✗ / ○ for every feature&lt;/span&gt;
&lt;span class="nb"&gt;sudo&lt;/span&gt; ./warden-setup units &lt;span class="nt"&gt;--install&lt;/span&gt;  &lt;span class="c"&gt;# only turns on the timers you need&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;check&lt;/code&gt; is the bit I like. It asks the running system what actually works, and tells you what to do about each ✗. It exits 1 while anything required is missing, so it works in a script too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;Everything after the demo. &lt;code&gt;init&lt;/code&gt; asks you questions, and the real install talks to your own machines, logs and Cloudflare account, so the weekly run stops at "the example config loads and the checks run". The guide says that next to each step.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/warden/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What would you want a homelab security console to tell you first thing in the morning?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>security</category>
      <category>tutorial</category>
      <category>ai</category>
      <category>linux</category>
    </item>
    <item>
      <title>Set up sieve in 5 minutes: find the CVEs that matter on a Linux box</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Mon, 26 Oct 2026 09:00:20 +0000</pubDate>
      <link>https://dev.to/iam-tech/set-up-sieve-in-5-minutes-find-the-cves-that-matter-on-a-linux-box-2ebh</link>
      <guid>https://dev.to/iam-tech/set-up-sieve-in-5-minutes-find-the-cves-that-matter-on-a-linux-box-2ebh</guid>
      <description>&lt;p&gt;I wrote &lt;a href="https://github.com/casareanderson/sieve" rel="noopener noreferrer"&gt;sieve&lt;/a&gt; last week to replace trivy in my homelab. A few people asked how to actually get it going, so this is the short version. The long version is &lt;a href="https://github.com/casareanderson/sieve/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;docs/SETUP.md&lt;/a&gt; in the repo.&lt;/p&gt;

&lt;p&gt;One thing I've done differently this time: the guide is tested. A GitHub job pulls the commands out of SETUP.md and runs them on a brand new Ubuntu machine every week. If a step breaks, the job goes red before you find out the hard way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqx3ope44sqwkecxj9da.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feqx3ope44sqwkecxj9da.png" alt="sieve's setup steps running on a fresh GitHub runner: install with pipx, scan the host, scan a running Debian 12 container" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's a real run from today. Funny thing is, a fresh GitHub runner isn't clean: 32,445 findings, 9 of them actively exploited (all kernel tool packages with no fix yet), 31 fixable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;You need Linux and Python 3.10 or newer. No database, no server, no API key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pipx &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/casareanderson/sieve
sieve &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it says &lt;code&gt;command not found&lt;/code&gt;, run &lt;code&gt;pipx ensurepath&lt;/code&gt; and open a new terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan the box you're on
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sieve host &lt;span class="nt"&gt;--limit&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First run is the slow one, it downloads the CISA known-exploited list and the EPSS scores and asks OSV.dev about every package. After that it's cached.&lt;/p&gt;

&lt;p&gt;Read it top down. A ⚠ in the KEV column means someone's exploiting it right now. A massive number with "0 fixable" is normal on a patched box, most of it is "affected, no fix released yet".&lt;/p&gt;

&lt;p&gt;The exit code is the useful bit for cron: &lt;code&gt;0&lt;/code&gt; nothing known-exploited, &lt;code&gt;1&lt;/code&gt; at least one KEV finding, &lt;code&gt;2&lt;/code&gt; part of the scan failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scan your containers
&lt;/h2&gt;

&lt;p&gt;This needs root, because container filesystems live under &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/root&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; sieve&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; containers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or run the collector on another Docker host with nothing installed there except python3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sieve collector | ssh root@dockerhost python3 - &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; packages.tgz
sieve containers &lt;span class="nt"&gt;--from-tar&lt;/span&gt; packages.tgz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Put it on a timer
&lt;/h2&gt;

&lt;p&gt;The line I'd actually use, only shouts about things you can fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sieve"&gt;&lt;code&gt;&lt;span class="err"&gt;0&lt;/span&gt; &lt;span class="err"&gt;7&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="err"&gt;1&lt;/span&gt;  &lt;span class="nx"&gt;sieve&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt; &lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;fixable&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;only&lt;/span&gt; &lt;span class="err"&gt;--&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt; &lt;span class="nx"&gt;high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's also a Docker image and a GitHub Action, both in the guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI can't prove
&lt;/h2&gt;

&lt;p&gt;Not much for this one. The weekly run covers install, host scan, JSON output, a real container scan and the Docker image. It can't tell you how your own distro feeds behave: Fedora, RHEL and Arch get their Python and container language packages checked, not the OS ones.&lt;/p&gt;

&lt;p&gt;Full guide: &lt;a href="https://github.com/casareanderson/sieve/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;github.com/casareanderson/sieve/blob/main/docs/SETUP.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Anyone else running a scanner on a timer at home? What do you do with the "no fix yet" pile?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>tutorial</category>
      <category>ai</category>
    </item>
    <item>
      <title>Everything I've open-sourced from my homelab, in one list</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:00:17 +0000</pubDate>
      <link>https://dev.to/iam-tech/everything-ive-open-sourced-from-my-homelab-in-one-list-1gke</link>
      <guid>https://dev.to/iam-tech/everything-ive-open-sourced-from-my-homelab-in-one-list-1gke</guid>
      <description>&lt;p&gt;People keep finding one of my repos and not the others, so here's the lot in one place. Everything below is free and on GitHub, and every one now has a setup guide in &lt;code&gt;docs/SETUP.md&lt;/code&gt; that a GitHub job runs on a fresh machine every week. If a guide stops working, the job goes red, so I find out before you do.&lt;/p&gt;

&lt;p&gt;Each guide also says what CI &lt;em&gt;can't&lt;/em&gt; prove (anything that needs your hardware or accounts). That bit's in there on purpose.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8phimsvvgxizzzqvc7k6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8phimsvvgxizzzqvc7k6.png" alt="warden's console: detections, vulnerabilities and decisions waiting for approval" width="800" height="987"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/warden" rel="noopener noreferrer"&gt;warden&lt;/a&gt;&lt;/strong&gt;: a homelab security console. Reads the logs your WAF can't see, scans your machines, proposes bans and patches, and waits for your ✅. Detect-only by default. For anyone self-hosting behind a Cloudflare tunnel. &lt;a href="https://github.com/casareanderson/warden/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/sieve" rel="noopener noreferrer"&gt;sieve&lt;/a&gt;&lt;/strong&gt;: finds the CVEs that matter on a Linux box and its running containers, using OSV.dev, CISA's known-exploited list and EPSS. No scanner binary, no database, no server. &lt;a href="https://github.com/casareanderson/sieve/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/farrier" rel="noopener noreferrer"&gt;farrier&lt;/a&gt;&lt;/strong&gt;: owner-approved patching as a standalone CLI. Plan, snapshot, apply (apt, npm, pip), and it always hands you the rollback. &lt;a href="https://github.com/casareanderson/farrier/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Homelab
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/zimablade-recipes" rel="noopener noreferrer"&gt;zimablade-recipes&lt;/a&gt;&lt;/strong&gt;: running a ZimaBlade like a server. Root through Docker, AppData backups that actually include your certificates, offsite photos, apps CasaOS can't remove, a slimmer Uptime Kuma. &lt;a href="https://github.com/casareanderson/zimablade-recipes/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/zimablade-gpu-immich" rel="noopener noreferrer"&gt;zimablade-gpu-immich&lt;/a&gt;&lt;/strong&gt;: a GPU in a ZimaBlade for Immich. The slot's real limits, the driver trap for older cards, and a watcher that tells you when the card falls off the bus. &lt;a href="https://github.com/casareanderson/zimablade-gpu-immich/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/garden-room-lights" rel="noopener noreferrer"&gt;garden-room-lights&lt;/a&gt;&lt;/strong&gt;: time-of-day lighting for a room of WLED strips, with brightness that respects each strip's power limit. &lt;a href="https://github.com/casareanderson/garden-room-lights/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/WLED_Voron--F1-Lights" rel="noopener noreferrer"&gt;WLED_Voron--F1-Lights&lt;/a&gt;&lt;/strong&gt;: Klipper macros that turn a Voron 2's LED strip an F1 colour for each print state. &lt;a href="https://github.com/casareanderson/WLED_Voron--F1-Lights/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  AI agents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/model-radar" rel="noopener noreferrer"&gt;model-radar&lt;/a&gt;&lt;/strong&gt;: finds free models that really make tool calls, and fails your agents over when the paid model or the credit runs out. &lt;a href="https://github.com/casareanderson/model-radar/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/toolsmith" rel="noopener noreferrer"&gt;toolsmith&lt;/a&gt;&lt;/strong&gt;: a Hermes agent that researches a replacement for one of your tools each week, tests it in a sandbox, and withholds anything it can't back with a measured fact. Needs Hermes and Proxmox. &lt;a href="https://github.com/casareanderson/toolsmith/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/ephor" rel="noopener noreferrer"&gt;ephor&lt;/a&gt;&lt;/strong&gt;: passive, detect-only monitor of what your AI agents connect to, download and can access. Reads &lt;code&gt;/proc&lt;/code&gt; and the socket table, so nothing can route around it. &lt;a href="https://github.com/casareanderson/ephor/blob/main/README.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/proctor" rel="noopener noreferrer"&gt;proctor&lt;/a&gt;&lt;/strong&gt;: an AI code reviewer you run from a terminal. Point it at a diff, it reports what's wrong and fact-checks every finding against the patch. &lt;a href="https://github.com/casareanderson/proctor/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Music
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/setlisted" rel="noopener noreferrer"&gt;setlisted&lt;/a&gt;&lt;/strong&gt;: a headless Spotify CLI for non-repeating discovery mixes, hour-by-hour event sets and playlist flow. &lt;a href="https://github.com/casareanderson/setlisted/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/spin-that-dice" rel="noopener noreferrer"&gt;spin-that-dice&lt;/a&gt;&lt;/strong&gt;: a jukebox dice for parties. Tap the die on a tablet, get a random track from one of 43 categories of music, and it plays. &lt;a href="https://github.com/casareanderson/spin-that-dice/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Shopping and eBay
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/sold-shadow" rel="noopener noreferrer"&gt;sold-shadow&lt;/a&gt;&lt;/strong&gt;: build your own eBay sold-price data from the official API, and measure whether it can replace the paid data you use. &lt;a href="https://github.com/casareanderson/sold-shadow/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/cex-uk-prices" rel="noopener noreferrer"&gt;cex-uk-prices&lt;/a&gt;&lt;/strong&gt;: CeX UK sell, cash and voucher prices and store stock, with the traps measured (£0 means they won't buy it, voucher isn't cash). &lt;a href="https://github.com/casareanderson/cex-uk-prices/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Writing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/blog-kit" rel="noopener noreferrer"&gt;blog-kit&lt;/a&gt;&lt;/strong&gt;: what I publish these posts with. A dev.to publisher with a leak check that refuses private IPs and keys, plus diagrams and covers. &lt;a href="https://github.com/casareanderson/blog-kit/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Just for fun
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/cryptonym-desk" rel="noopener noreferrer"&gt;cryptonym-desk&lt;/a&gt;&lt;/strong&gt;: name the films and characters you like and it issues you a spy-style cryptonym. One HTML file. &lt;a href="https://github.com/casareanderson/cryptonym-desk/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/casareanderson/cryptonym-desk-sanity" rel="noopener noreferrer"&gt;cryptonym-desk-sanity&lt;/a&gt;&lt;/strong&gt;: the same desk rebuilt on Astro and a public Sanity dataset. &lt;a href="https://github.com/casareanderson/cryptonym-desk-sanity/blob/main/docs/SETUP.md" rel="noopener noreferrer"&gt;setup&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one of these is useful to you, a star on GitHub genuinely helps people find it. Which one should get a proper write-up next?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>showdev</category>
      <category>opensource</category>
      <category>linux</category>
      <category>ai</category>
    </item>
    <item>
      <title>16 containers on 2 cores: what my ZimaBlade home server actually uses</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:00:17 +0000</pubDate>
      <link>https://dev.to/iam-tech/16-containers-on-2-cores-what-my-zimablade-home-server-actually-uses-1p57</link>
      <guid>https://dev.to/iam-tech/16-containers-on-2-cores-what-my-zimablade-home-server-actually-uses-1p57</guid>
      <description>&lt;p&gt;People ask if a little box like a ZimaBlade can really run a homelab. Mine runs 16 containers on a dual-core Celeron N3350 from 2016. So I measured what each one actually uses.&lt;/p&gt;

&lt;p&gt;The box: 2 cores (1.1 GHz, boosts to 2.4), 16 GB of RAM, two 6 TB IronWolf drives on SATA. It had been up 17 days when I took these, and Docker hadn't restarted a single container.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAM: Immich is most of it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwyx6953rf7nzwew3iad.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcwyx6953rf7nzwew3iad.png" alt="docker stats on the ZimaBlade: per-container CPU and memory" width="799" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Added up, the 16 containers use just under 3 GB. The Immich stack (server, Postgres, machine learning, Redis) is about 2.2 GB of that, roughly three quarters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;immich-server: 1.34 GB&lt;/li&gt;
&lt;li&gt;immich-postgres: 602 MB&lt;/li&gt;
&lt;li&gt;immich-machine-learning: 239 MB&lt;/li&gt;
&lt;li&gt;uptime-kuma: 183 MB&lt;/li&gt;
&lt;li&gt;npmplus (reverse proxy): 137 MB&lt;/li&gt;
&lt;li&gt;homepage: 112 MB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else is tiny. Authelia, the thing every login goes through, is 32 MB. The Cloudflare tunnel is 29 MB. CrowdSec is 57 MB.&lt;/p&gt;

&lt;p&gt;The OS says 5.4 GB "used" overall, but 10.5 GB is still available because the rest is file cache it'll hand back. It's also got about 1 GB sitting in swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  CPU: idle, mostly
&lt;/h2&gt;

&lt;p&gt;Two snapshots 20 seconds apart and almost everything was under 1% CPU. The busiest were Uptime Kuma (4% then 1%) and immich-server (under 1%, then nearly 3%). Earlier in the day the load average sat around 0.9 on 2 cores, so about half busy, and it sat at 41-44 °C.&lt;/p&gt;

&lt;p&gt;So for always-on stuff that mostly waits for requests, 2 old cores are fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where 2 cores hurt
&lt;/h2&gt;

&lt;p&gt;Here's the bit I didn't expect. While I was taking these measurements, the 1-minute load jumped from about 0.9 to 3.4 and the kernel's CPU pressure figure said something was stuck waiting for CPU about 75% of the last 10 seconds:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vl23liffzskshgcll6b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vl23liffzskshgcll6b.png" alt="Load average and CPU pressure while measuring" width="800" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker stats&lt;/code&gt; itself isn't free, and I can't promise nothing else kicked off at the same time. But it shows the shape: with only 2 cores, any burst queues up behind everything else. Idle services are cheap. Bursty work, like ML indexing, video transcoding or someone uploading 300 photos, is where this CPU runs out. And my Immich machine learning runs on the CPU (the P600 is too old for Immich's GPU build), so that work lands on these same 2 cores.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disk, not CPU, is the other limit
&lt;/h2&gt;

&lt;p&gt;The fast storage is a 17 GB eMMC partition, now 72% full, mostly a 5.2 GB swap file and 3 GB of OS update files. Docker's images (12.5 GB, 5.4 GB of it old ones I could prune) live on the big array, which is only 36% used. So the thing I watch isn't CPU or RAM, it's that little eMMC.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it means if you're picking hardware
&lt;/h2&gt;

&lt;p&gt;From my numbers, not a benchmark:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RAM was the right thing to have plenty of. 16 GB means Immich can be greedy and nothing else notices.&lt;/li&gt;
&lt;li&gt;The CPU is fine for services that idle. It's not fine for bursts, so keep heavy jobs off it or schedule them.&lt;/li&gt;
&lt;li&gt;Real SATA mattered more to me than CPU speed. Photos live on proper drives, not USB.&lt;/li&gt;
&lt;li&gt;On paper I'd expect a Raspberry Pi 5 to beat this CPU. I haven't benchmarked that yet. That's the next post, with prices and power once I've got real numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pi or mini PC for your homelab? And what's the biggest RAM hog on yours?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>performance</category>
      <category>ai</category>
      <category>learning</category>
      <category>devops</category>
    </item>
    <item>
      <title>What a ZimaBlade can't do: the limits I hit running it as my home server</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Fri, 23 Oct 2026 08:00:19 +0000</pubDate>
      <link>https://dev.to/iam-tech/what-a-zimablade-cant-do-the-limits-i-hit-running-it-as-my-home-server-2ig5</link>
      <guid>https://dev.to/iam-tech/what-a-zimablade-cant-do-the-limits-i-hit-running-it-as-my-home-server-2ig5</guid>
      <description>&lt;p&gt;I've run a ZimaBlade as my main home server for a few months now. It does photos (Immich), single sign-on, the reverse proxy, monitoring, CrowdSec and a Cloudflare tunnel. I like it. But it's a small board and it has proper limits, and most of them I found the hard way.&lt;/p&gt;

&lt;p&gt;I already wrote up &lt;a href="https://dev.to/iam-tech/running-a-zimablade-like-a-server-not-an-appliance-48ol"&gt;how I set it up as a server rather than an appliance&lt;/a&gt; and &lt;a href="https://dev.to/iam-tech/a-dead-gpu-degrades-gracefully-a-working-one-crash-loops-bdj"&gt;the saga of the Quadro P600 in it&lt;/a&gt;. This one is the list I wish I'd had before buying.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Power comes in over USB-C, and there isn't much of it
&lt;/h2&gt;

&lt;p&gt;The stock power brick is 12 V / 3 A, so 36 W, over USB-C. Kits since late 2025 come with a 60 W one. The board, RAM and two SATA drives already sit around 25-30 W, so there is no headroom for anything else.&lt;/p&gt;

&lt;p&gt;I got this wrong in my own repo at first. I'd written instructions about a barrel jack. There is no barrel jack, it's USB-C. Fixed now, but if you're planning to power a riser or a GPU off the same supply, don't. Anything hungry needs its own PSU.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The PCIe slot is small in every direction
&lt;/h2&gt;

&lt;p&gt;What the slot actually reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PCIe 2.0 x4, so about 2 GB/s at best&lt;/li&gt;
&lt;li&gt;a 25 W slot power limit, so a 40 W card can't run off it directly&lt;/li&gt;
&lt;li&gt;no hot-plug: a card only gets found at a cold boot, rescanning does nothing&lt;/li&gt;
&lt;li&gt;no Above 4G decoding: the biggest PCI window is about 1.25 GB, fine for a 2 GB card, probably a wall for anything with big VRAM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So "stick a GPU in it" really means a riser, an external PSU and a small, old card.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Two power supplies, two grounds, one very confused GPU
&lt;/h2&gt;

&lt;p&gt;My riser setup ran the Zima off its own unearthed brick and the GPU off an earthed ATX PSU. Two ground domains, joined only by the thin grounds in the riser's cable. The fan spun, so I assumed power was fine. The PCIe link never trained at all.&lt;/p&gt;

&lt;p&gt;After a lot of physical faff in the loft it did enumerate. Then it ran for about 8.5 hours and dropped off:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F783lwvcbvhgb9fhjwf6u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F783lwvcbvhgb9fhjwf6u.png" alt="The kernel log when the GPU fell off the bus" width="800" height="199"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The link had also dropped itself to PCIe 1.0 x1. These days a small watcher script records how long the card survives each boot and only shouts when it changes state, rather than every 15 minutes. The full story's in the P600 post.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The OS is read-only and you don't get sudo
&lt;/h2&gt;

&lt;p&gt;ZimaOS is immutable. &lt;code&gt;/&lt;/code&gt; is a read-only squashfs that always shows 100% full. That's by design, it's not a full disk, but it scared me the first time.&lt;/p&gt;

&lt;p&gt;The one SSH user can run Docker without sudo, but sudo itself wants a password I don't have. So editing anything root-owned (a systemd unit, &lt;code&gt;/etc/exports&lt;/code&gt;) means borrowing root through a privileged container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--privileged&lt;/span&gt; &lt;span class="nt"&gt;--pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="nt"&gt;-v&lt;/span&gt; /:/host alpine &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;chroot&lt;/span&gt; /host systemctl status some-unit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that looks dodgy, it is a bit. But being in the docker group already means being root in all but name, so it's not adding a hole, it's just honest about the one that's there.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The app store deleted my dashboard. Twice.
&lt;/h2&gt;

&lt;p&gt;I ran Homepage as a CasaOS app. In July it vanished: container gone, config still on disk. I reinstalled it from the leftover compose file. In September it vanished again, and this time the compose file had gone too.&lt;/p&gt;

&lt;p&gt;Now it's a plain &lt;code&gt;docker run&lt;/code&gt; with &lt;code&gt;--restart unless-stopped&lt;/code&gt;, and CasaOS has no say in whether it lives. Same for Uptime Kuma when I moved it onto the Zima. Anything I care about stays out of the app store.&lt;/p&gt;

&lt;p&gt;Related trap: back in July five of my containers had &lt;code&gt;restart=no&lt;/code&gt;. CasaOS starts them at boot, so it looked fine, but a container that crashed mid-life just stayed dead. One sat dead for six days before I noticed. I switched them all to &lt;code&gt;unless-stopped&lt;/code&gt; then. When I checked again for this post, 8 of the 16 running were on &lt;code&gt;restart=no&lt;/code&gt; again, so it's a check worth repeating.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. 17 GB of eMMC fills up fast
&lt;/h2&gt;

&lt;p&gt;The fast storage is a 29 GB eMMC, with a 17 GB &lt;code&gt;/DATA&lt;/code&gt; partition. The two big drives are a separate 5.5 TB array.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl56qutmszitws7npaszw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl56qutmszitws7npaszw.png" alt="df and docker system df on the ZimaBlade: eMMC 72% full, array 36%" width="799" height="277"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;12 GB used and 4.7 GB free. The biggest things on it are a 5.2 GB swap file and 3 GB of OS update files. Docker's 12.5 GB of images (5.4 GB of that is old images I could prune) actually sit on the array. Rule I follow now: anything big or cold goes on the array, never &lt;code&gt;/DATA&lt;/code&gt;. I also share a "coldstore" folder off the array to my Proxmox boxes over NFS, mounted with &lt;code&gt;nofail&lt;/code&gt; and automount so a Zima outage can never stop them booting.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. It can't watch itself
&lt;/h2&gt;

&lt;p&gt;Uptime Kuma runs on the Zima. Which means when the Zima dies, the thing that's meant to tell me is dead too. So a script on a different box checks Kuma every 5 minutes and restarts the container over SSH if it's gone. I tested it by stopping Kuma, it was back in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  So would I buy one again?
&lt;/h2&gt;

&lt;p&gt;Yeah, for what it is. Two real SATA ports, 16 GB of RAM and an x86 CPU that runs every Docker image without thinking about ARM builds is a lot in a box that size. But it's a 2016-era dual-core Celeron with a weedy PCIe slot and a USB-C power brick, and you have to work around that rather than pretend it's a NAS appliance. Next post is what 16 containers actually cost it.&lt;/p&gt;

&lt;p&gt;What's the limit that bit you on your homelab box?&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>ai</category>
      <category>discuss</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>FontForge brand fonts: why changeWeight failed and offset outlines worked</title>
      <dc:creator>Christian Anderson</dc:creator>
      <pubDate>Thu, 22 Oct 2026 08:00:37 +0000</pubDate>
      <link>https://dev.to/iam-tech/fontforge-brand-fonts-why-changeweight-failed-and-offset-outlines-worked-24bh</link>
      <guid>https://dev.to/iam-tech/fontforge-brand-fonts-why-changeweight-failed-and-offset-outlines-worked-24bh</guid>
      <description>&lt;p&gt;I run two side projects that look nothing alike on purpose. The tech one (homelab notes, tips, the Server reels) is black and acid green with IBM Plex Sans Condensed Bold for headlines. The poster one is gold on black with Anton. Both fonts are free and brilliant, which is the problem. Half the internet uses them.&lt;/p&gt;

&lt;p&gt;On 29 September I wanted something "a little off what we use, sharper lines, more impact". Not a new typeface, I'm not a type designer. Just my existing fonts, a bit heavier with a signature detail, so a headline looks like mine before you read it.&lt;/p&gt;

&lt;p&gt;Here's what broke, the licence rules, and the colours, which were less tidy than I thought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tools
&lt;/h2&gt;

&lt;p&gt;FontForge has Python bindings (&lt;code&gt;apt install python3-fontforge&lt;/code&gt; on Debian/Ubuntu). The whole build is one 82-line script: open the source font, loop over every glyph, change the outline, rename the font, write &lt;code&gt;.otf&lt;/code&gt; and &lt;code&gt;.ttf&lt;/code&gt;, then render a specimen with Pillow so I can actually see it.&lt;/p&gt;

&lt;p&gt;Two fonts come out of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iC Volt Condensed&lt;/strong&gt;, from IBM Plex Sans Condensed Bold, for the tech brand&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C1 Cut&lt;/strong&gt;, from Anton Regular, for the poster brand&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkomd36h4bcmmz05fqkmx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkomd36h4bcmmz05fqkmx.png" alt="Specimen: Anton vs C1 Cut in gold, then Plex Sans Condensed Bold vs iC Volt Condensed in volt green, each with a headline line and a body line with digits" width="799" height="524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  v1: changeWeight made a mess
&lt;/h2&gt;

&lt;p&gt;FontForge has a built-in for this, &lt;code&gt;glyph.changeWeight()&lt;/code&gt;. One call per glyph, give it a stroke amount, job done. So v1 did exactly that: 3% of the em for the Anton one, 3.4% for Plex, then tightened the letter spacing by 1.2% and 1.8%.&lt;/p&gt;

&lt;p&gt;It never errored. It was wrapped in a &lt;code&gt;try/except: pass&lt;/code&gt; (not proud of that), so I went back and counted: 596 glyphs on Anton, 330 on Plex, zero exceptions.&lt;/p&gt;

&lt;p&gt;It just looked rubbish. The heavy Anton one clogged up, the holes in R, G and 8 nearly closed. The R leg grew a weird curled foot. The S on the Plex one got little nicks at its ends. And the tighter spacing made the clogging worse, letters started kissing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumduqdal7wju5v4kefpk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumduqdal7wju5v4kefpk.png" alt="Close-up: v1 changeWeight on the left vs v2 offset with mitre joins on the right, for both fonts. v1 has clogged counters, a curled R foot and nicked S terminals" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That left-hand side is a rebuild of v1 from the old script today, so it's real output, not a mock up.&lt;/p&gt;

&lt;h2&gt;
  
  
  v2: offset the outline yourself
&lt;/h2&gt;

&lt;p&gt;What I actually wanted was "the same letter, a bit fatter, corners still sharp". So v2 does that directly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;removeOverlap()&lt;/code&gt; so the glyph is one clean outline&lt;/li&gt;
&lt;li&gt;take a copy of the outline and stroke it with a circular pen, butt caps and &lt;strong&gt;mitre joins&lt;/strong&gt;, keeping only the outer band&lt;/li&gt;
&lt;li&gt;add that band back onto the original body and merge it with &lt;code&gt;removeOverlap()&lt;/code&gt; again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mitre join is the bit that matters. A round join would soften every corner, which is the opposite of "sharper". A mitre keeps the point.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreground&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreground&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;band&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;circular&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;weight&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;em&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;butt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;miter&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;removeinternal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;band&lt;/span&gt;
&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreground&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;
&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeOverlap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;v2 also uses way less weight: 1% of the em on C1 Cut, 2% on iC Volt. A little goes a long way when the shapes aren't getting mangled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cut corners
&lt;/h2&gt;

&lt;p&gt;The signature detail is a chamfer, like a machined edge. For every corner point the script checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the point and both neighbours are on-curve, so it's a straight line meeting a straight line&lt;/li&gt;
&lt;li&gt;both edges are longer than 3x the chamfer size, so tiny details don't get eaten&lt;/li&gt;
&lt;li&gt;the angle is between 60° and 120°&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If all three pass, the corner point gets swapped for two points a set distance back along each edge. That's 3% of the em on C1 Cut and 2.8% on iC Volt. Curves are never touched, so S, G and 8 keep their shape and only the hard corners on things like M, T and K get cut.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiauxpeui0o2jyzwl0utk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiauxpeui0o2jyzwl0utk.png" alt="Close-up of MTK: the original fonts on the left, the new ones on the right with small 45 degree cuts on the corners" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After that it's housekeeping: &lt;code&gt;correctDirection()&lt;/code&gt;, &lt;code&gt;addExtrema()&lt;/code&gt;, &lt;code&gt;round()&lt;/code&gt;, then spacing. C1 Cut went 0.4% looser, iC Volt 0.6% tighter, split evenly across both side bearings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The licence bit (do read this)
&lt;/h2&gt;

&lt;p&gt;Both source fonts are under the SIL Open Font License 1.1. It's a generous licence, but it has rules for modified versions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reserved Font Names.&lt;/strong&gt; IBM's licence for Plex says "Reserved Font Name 'Plex'". A modified version can't use that name. So no "Plex Volt" or anything like it, hence iC Volt Condensed. Anton doesn't reserve a name, but I renamed it to C1 Cut anyway so nobody mixes it up with the real thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rename it properly.&lt;/strong&gt; The script strips the old Family, Full name, PostScript name, Unique ID, Preferred Family, WWS and Trademark entries from the name table and writes new ones. It appends "Modified 2026 ... (SIL Open Font License 1.1)" to the copyright, it doesn't replace it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship the licence with the font.&lt;/strong&gt; And the modified font stays under the OFL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't sell the font files on their own.&lt;/strong&gt; You can use them in stuff you sell, but the font itself isn't a product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where I got it wrong: the first licence file the script wrote for iC Volt was a three-line notice, credit and a link, without the actual licence text. When the font went on my site I shipped the proper file instead: the notice plus the full OFL 1.1 text, served next to the font with a "Font licence" link in the footer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they're used right now
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;iC Volt&lt;/strong&gt; went live on 8 October on my tech site, headlines only. It's a Latin-only subset made with fontTools, 12,232 bytes as WOFF2. Body text stays IBM Plex Sans and Plex Mono. The Friday newsletter template uses it too, with Impact as the fallback because loads of email clients won't load web fonts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C1 Cut&lt;/strong&gt; isn't used anywhere yet. The poster renderers still use Anton, and the tech Instagram renderer still uses Plex Condensed Bold. Swapping is mostly changing a font constant in each renderer, but I'd rather the owner of each brand (me, wearing a different hat) looks at it on real posts first.&lt;/p&gt;

&lt;h2&gt;
  
  
  And the colours
&lt;/h2&gt;

&lt;p&gt;I'd been calling these "colour packs" in my head. Being honest, there's no colour pack system. What exists:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ferpd5o247s3qxobq6934.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ferpd5o247s3qxobq6934.png" alt="Palette swatches rendered from the hex values in the code: the eight IAMC/TECH tokens, the THEC1LAB gold trio, and five product colourways" width="800" height="516"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tech brand&lt;/strong&gt; has one palette of eight tokens: ink &lt;code&gt;#0B0C0E&lt;/code&gt;, panel &lt;code&gt;#15181C&lt;/code&gt;, grid &lt;code&gt;#262A30&lt;/code&gt;, paper &lt;code&gt;#F1EFE8&lt;/code&gt;, mute &lt;code&gt;#8B9099&lt;/code&gt;, volt &lt;code&gt;#D7FF3A&lt;/code&gt;, red &lt;code&gt;#FF5A4E&lt;/code&gt;, green &lt;code&gt;#39D98A&lt;/code&gt;. Volt is the headline and accent colour, red and green are for the do/don't slides and result labels. It lives as a &lt;code&gt;BRAND&lt;/code&gt; dict in the Instagram renderer, as RGB tuples in the reel renderer, and as CSS variables on the site.&lt;/p&gt;

&lt;p&gt;Three copies, no shared file. And it's already drifted. The newer "Server explains" reel script defines its own red and green, &lt;code&gt;(255, 92, 92)&lt;/code&gt; and &lt;code&gt;(92, 220, 120)&lt;/code&gt;, which are close to the brand ones but not the same, plus an amber the palette doesn't have. Nobody would spot it on a phone, but that's how a brand goes muddy. Next job is one palette file that everything imports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The poster brand&lt;/strong&gt; is simpler at the top: gold &lt;code&gt;#D4AF37&lt;/code&gt; on &lt;code&gt;#0B0B0B&lt;/code&gt; with &lt;code&gt;#F5F3EE&lt;/code&gt; text in the video renderer. The posters themselves use named colourways, which is the closest thing I've got to actual packs. Each one is a dict with a background, a gradient or solid fill for the glyphs, an accent, title, subtitle and fine-print colours, plus a flag for light or dark. There are five base ones (noir-gold, noir-silver, paper, neon, pop), and limited runs add their own with &lt;code&gt;COLOURWAYS.update()&lt;/code&gt; so they never reuse a base one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No errors isn't the same as working. changeWeight ran clean on 926 glyphs and still made rubbish. Render a specimen every build.&lt;/li&gt;
&lt;li&gt;Mitre joins if you want weight without losing sharp corners.&lt;/li&gt;
&lt;li&gt;Check the licence for a Reserved Font Name before picking a name.&lt;/li&gt;
&lt;li&gt;Colours in one file from day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Has anyone made a proper derivative font for their own stuff? I'd like to know how you checked yours at small sizes, because I've only really looked at mine big.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;🤖 &lt;em&gt;Drafted with AI assistance from my own homelab notes, logs and repos, then reviewed and edited before publishing.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>python</category>
      <category>opensource</category>
      <category>discuss</category>
      <category>career</category>
    </item>
  </channel>
</rss>
