<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://ferbass.xyz/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ferbass.xyz/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-07-22T14:47:27+09:00</updated><id>https://ferbass.xyz/feed.xml</id><title type="html">ferbass.xyz</title><subtitle>Notes on software engineering, macOS, Ruby, AI, and technical experiments by Fernando Bass.</subtitle><author><name>Fernando Bass</name></author><entry><title type="html">A QR Code Generator Without the Ads</title><link href="https://ferbass.xyz/2026/07/21/a-qr-code-generator-without-the-ads.html" rel="alternate" type="text/html" title="A QR Code Generator Without the Ads" /><published>2026-07-21T00:00:00+09:00</published><updated>2026-07-21T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/07/21/a-qr-code-generator-without-the-ads</id><content type="html" xml:base="https://ferbass.xyz/2026/07/21/a-qr-code-generator-without-the-ads.html"><![CDATA[<p>I needed a QR code at work. Nothing fancy — point a phone at a screen,
land on a test URL, confirm the thing works. So I did what everyone does:
searched for a QR code generator, clicked the first result, and landed on
a page with a cookie banner, two ad slots, a newsletter popup, and a
“Sign up to unlock high resolution!” button sitting between me and a
200x200 pixel image with a watermark.</p>

<p>I did that maybe four times in a week before I got annoyed enough to stop.
The whole thing now lives at
<a href="https://qrcode.ferbass.xyz">qrcode.ferbass.xyz</a>, it took an evening, and
it cost me nothing to run.</p>

<p><img src="/img/posts/2026-07-21-a-qr-code-generator-without-the-ads/generator.png" alt="The QR code generator: a text field on the left, the generated code on the right" /></p>

<h2 id="the-part-that-actually-bothered-me">The part that actually bothered me</h2>

<p>The ads are the obvious complaint, but they are not the real problem. The
real problem is that a lot of these sites quietly hand you a <strong>dynamic</strong>
QR code: instead of encoding your URL, they encode a short link on <em>their</em>
domain that redirects to your URL. It looks identical. It scans fine in
the demo.</p>

<p>And it means someone else owns the thing you just printed on a slide.
They can count every scan, and if the free tier lapses or the company
folds, your code stops working. For a throwaway test that is merely
distasteful. For anything that outlives the afternoon, it is a genuine
trap.</p>

<p>A static QR code has none of that. The URL is <em>in</em> the black and white
squares. Nothing to expire, nobody in the middle.</p>

<h2 id="qr-codes-do-not-need-a-server">QR codes do not need a server</h2>

<p>Here is the thing that makes this an evening project rather than a
product: generating a QR code is pure computation. Text goes in, a grid
of dark and light modules comes out. There is no lookup, no registry, no
network call anywhere in the process.</p>

<p>Which means the entire tool can run in the browser. Mine does. There is
no backend, no API, no database, and no request leaving the page — the
Wi-Fi password you type never goes anywhere except into a canvas element.
That is not a privacy feature I bolted on, it is just what happens when
you stop over-thinking the problem.</p>

<p>I used <a href="https://www.nayuki.io/page/qr-code-generator-library">Project Nayuki’s QR library</a>
for the encoding itself. It is MIT licensed and genuinely well written,
and I have no interest in implementing Reed-Solomon error correction by
hand. I compiled it from its TypeScript source and vendored the result
into the repo rather than pulling it from a CDN, so there is no third
party to trust and nothing to break when someone else’s bucket
disappears.</p>

<h2 id="the-actual-product-is-the-payload-formats">The actual product is the payload formats</h2>

<p>Encoding text is the solved part. What makes a QR generator useful is
knowing what text to encode, because a QR code that joins you to a Wi-Fi
network is not magic — it is a plain string in a format your phone’s
camera app recognises:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WIFI:T:WPA;S:my-network;P:hunter2;;
</code></pre></div></div>

<p>That is the whole trick. Same story everywhere else:</p>

<table>
  <thead>
    <tr>
      <th>What you want</th>
      <th>What gets encoded</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Open a link</td>
      <td><code class="language-plaintext highlighter-rouge">https://example.com</code></td>
    </tr>
    <tr>
      <td>Send an email</td>
      <td><code class="language-plaintext highlighter-rouge">mailto:hi@example.com?subject=Hello</code></td>
    </tr>
    <tr>
      <td>Compose a text</td>
      <td><code class="language-plaintext highlighter-rouge">SMSTO:+819012345678:message here</code></td>
    </tr>
    <tr>
      <td>Call a number</td>
      <td><code class="language-plaintext highlighter-rouge">tel:+819012345678</code></td>
    </tr>
    <tr>
      <td>Save a contact</td>
      <td>a <code class="language-plaintext highlighter-rouge">BEGIN:VCARD</code> block</td>
    </tr>
  </tbody>
</table>

<p>So the tool has a tab per format and builds the string for you. Six of
them: text/URL, Wi-Fi, email, SMS, phone, and contact card. That covers
essentially everything I have ever needed.</p>

<h2 id="the-bug-i-would-definitely-have-shipped">The bug I would definitely have shipped</h2>

<p>Look at the Wi-Fi format again and notice that <code class="language-plaintext highlighter-rouge">;</code> and <code class="language-plaintext highlighter-rouge">:</code> are
structural. Now ask what happens when the password contains a semicolon.</p>

<p>The format’s answer is backslash escaping, for <code class="language-plaintext highlighter-rouge">\ ; , : "</code> — and if you
skip it, you get a QR code that generates perfectly, looks perfectly
normal, and silently fails to join the network. The failure is invisible
right up until you are standing in front of someone whose Wi-Fi will not
connect.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">escWifi</span> <span class="o">=</span> <span class="p">(</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">([\\</span><span class="sr">;,:"</span><span class="se">])</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">"</span><span class="se">\\</span><span class="s2">$1</span><span class="dl">"</span><span class="p">);</span>
</code></pre></div></div>

<p><img src="/img/posts/2026-07-21-a-qr-code-generator-without-the-ads/wifi-options.png" alt="The Wi-Fi tab with a semicolon in the network name and a colon in the password" /></p>

<p>Which raises the question of how you <em>test</em> a QR code. Rendering one and
eyeballing it tells you nothing — every QR code looks correct. So I did
the obvious thing and closed the loop: encode a payload with my code,
then decode the resulting matrix with a completely different library
(<a href="https://github.com/cozmo/jsQR">jsQR</a>), and assert the string that comes
back out is byte-for-byte what went in.</p>

<p>That caught what I wanted it to catch. The suite runs a network name and
password stuffed with <code class="language-plaintext highlighter-rouge">;</code> and <code class="language-plaintext highlighter-rouge">:</code>, a multi-line email body, a vCard with
a comma in the company name, Japanese text with an emoji, and a
1200-character blob for good measure. All of them round-trip. The
generator I would have shipped without that test would have looked
identical and been wrong.</p>

<h2 id="where-it-lives">Where it lives</h2>

<p>I run this blog on Google App Engine, and it turns out adding a subdomain
to an existing project is almost free. A small <code class="language-plaintext highlighter-rouge">app.yaml</code> declares a
static service, one routing entry points the hostname at it, and that is
the entire infrastructure. Because it scales to zero and serves nothing
but static files, the running cost rounds to zero — which is the right
price for a tool that exists so I never have to close a newsletter popup
again.</p>

<p>I did briefly wonder whether this deserved its own repository. It does
not. The file that routes hostnames to services already lives in the blog
repo, so a separate repo would have meant two deploys that need to stay
in sync, in exchange for nothing.</p>

<h2 id="worth-an-evening">Worth an evening</h2>

<p>There is a category of tool where the free web version is worse than what
you could build yourself in an evening, and QR codes sit right in the
middle of it. The hard part — the encoding — is a library someone else
already wrote and gave away. What is left is a form, some string
formatting, and knowing that <code class="language-plaintext highlighter-rouge">;</code> needs escaping.</p>

<p>The result is a page I control, that works offline, that does not phone
home, and that will still generate the same codes in five years. Most of
the small tools I use every day could be that, and I keep forgetting it
until something makes me annoyed enough to check.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="tools" /><category term="projects" /><category term="qrcode" /><category term="javascript" /><category term="app-engine" /><category term="side-project" /><category term="self-hosting" /><summary type="html"><![CDATA[I needed a QR code at work. Nothing fancy — point a phone at a screen, land on a test URL, confirm the thing works. So I did what everyone does: searched for a...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Optimizing This Blog</title><link href="https://ferbass.xyz/2026/06/27/optimizing-this-blog.html" rel="alternate" type="text/html" title="Optimizing This Blog" /><published>2026-06-27T00:00:00+09:00</published><updated>2026-06-27T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/27/optimizing-this-blog</id><content type="html" xml:base="https://ferbass.xyz/2026/06/27/optimizing-this-blog.html"><![CDATA[<p>A little while ago I wrote about <a href="/2026/06/20/how-ai-helped-me-blog-again.html">how AI got me writing again</a>.
Once the words started flowing, I ran into a different problem: the blog
itself had been sitting mostly untouched for years, and it showed. A plain
default theme, no dark mode, ugly URLs, a couple of broken links, and an
SEO setup that was quietly working against me.</p>

<p>None of that stops you from publishing. But once you are publishing
regularly, the small rough edges start to bug you. So I spent a few
evenings tuning the place up. No single change here is impressive on its
own. Together they make the blog feel cared for, which is the whole point.</p>

<p>Here is what I changed and why.</p>

<h2 id="the-reading-experience">The reading experience</h2>

<p>This is where most of the work went, because it is what readers actually
feel.</p>

<p><strong>Dark mode.</strong> The big one. It follows your operating system preference by
default, and there is a small toggle in the nav if you want to override it.
Your choice sticks between visits, and a tiny inline script applies the
theme before the page paints so there is no white flash on load. Getting a
dev blog to look right in dark mode is mostly about the code blocks, so I
spent the most time there making the syntax colors readable on a dark
background.</p>

<p><strong>Reading time.</strong> Every post now shows an estimate next to the date. It is
a rough word count divided by a normal reading speed, nothing clever, but
it sets expectations before someone commits to a wall of text.</p>

<p><strong>Copy buttons on code blocks.</strong> Most of what I write is commands and
config. Asking people to carefully select a multi line block is rude when a
button can do it. Every code block now has one that copies the whole thing
and confirms it worked.</p>

<p><strong>Anchor links on headings.</strong> Hover a section heading and a small link
appears so you can grab a direct URL to that exact spot. Handy for the
cheatsheet style posts where people want to link a single section.</p>

<p><strong>Navigation.</strong> Posts now have previous and next links at the bottom so you
do not dead end. Tags are visible and clickable, with a page to browse
everything by topic. The home page is grouped by year instead of being one
endless list.</p>

<h2 id="urls-that-do-not-embarrass-me">URLs that do not embarrass me</h2>

<p>My old setup put post categories into the URL. That meant a post lived at
something like <code class="language-plaintext highlighter-rouge">/homelab/networking/2026/05/28/some-post.html</code>, and the
casing was inconsistent across posts. Worse, categories end up in the URL
means that renaming a category breaks the link forever.</p>

<p>I switched to clean, stable URLs based only on the date and title. The
catch with changing URLs is that any existing link to the old address
breaks. So for the handful of posts that were already live, I added
permanent redirects from the old path to the new one. Old links keep
working, new links are clean, nobody notices the seam.</p>

<h2 id="seo-that-stops-fighting-me">SEO that stops fighting me</h2>

<p>This was the surprising one. I own a few domains that all pointed at the
same blog. I assumed more domains meant more reach. It is closer to the
opposite. Search engines see the same content on several addresses and have
to guess which one is the real one, splitting whatever ranking signal the
content earned across all of them.</p>

<p>There is no dramatic penalty for this, but it is wasted potential. The fix
is to pick one canonical domain and send everything else to it with
permanent redirects, so all the signal consolidates in one place. That is
what I did. One home, every other domain forwards to it.</p>

<p>While I was in there I also bumped the RSS feed to carry more posts, since
it was only exposing the ten most recent. If you read via a feed reader,
<a href="/feed.xml">the feed</a> is there and now reaches further back.</p>

<h2 id="the-boring-part-that-makes-the-rest-possible">The boring part that makes the rest possible</h2>

<p>The blog builds and deploys itself. I push to the main branch, a workflow
builds the static site and ships it, and the change is live a minute later
without me touching a server. I wrote about that pipeline before, but I
tightened it here: it now fingerprints the built site and skips the deploy
entirely when nothing actually changed, so a typo fix in a draft does not
trigger a pointless redeploy. I also pulled my dependencies up to current
versions while I had the hood open.</p>

<p>None of this is visible to a reader. That is exactly why it is easy to put
off, and exactly why it is worth doing once so you never think about it
again.</p>

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

<p>The pattern across all of these is that the unglamorous changes compound.
Dark mode gets the attention, but the clean URLs, the redirects, the feed,
and the deploy guardrails are what make the blog feel like a real,
maintained thing instead of an abandoned one. Each change took an evening
at most. None of them required rebuilding anything from scratch.</p>

<p>If your own blog has gone quiet, the lesson from the
<a href="/2026/06/20/how-ai-helped-me-blog-again.html">last post</a> was to remove
whatever stops you from writing. The lesson from this one is the follow up:
once you are writing again, spend a little time making the place worth
landing on. Small wins, one evening at a time.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="meta" /><category term="jekyll" /><category term="jekyll" /><category term="seo" /><category term="dark-mode" /><category term="rss" /><category term="ci" /><category term="performance" /><summary type="html"><![CDATA[A little while ago I wrote about how AI got me writing again. Once the words started flowing, I ran into a different problem: the blog itself had been sitting...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">How AI Helped Me Blog Again</title><link href="https://ferbass.xyz/2026/06/20/how-ai-helped-me-blog-again.html" rel="alternate" type="text/html" title="How AI Helped Me Blog Again" /><published>2026-06-20T00:00:00+09:00</published><updated>2026-06-20T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/20/how-ai-helped-me-blog-again</id><content type="html" xml:base="https://ferbass.xyz/2026/06/20/how-ai-helped-me-blog-again.html"><![CDATA[<p>I have had this blog for over a decade. If you scroll far enough back you
will find Portuguese posts about Objective-C and cocos2d from 2010, a
scatter of notes through the years, and then long, quiet gaps. The kind of
gaps where you tell yourself you will write something next week, and next
week never shows up. The blog was not dead. It was just asleep, and I had
mostly made peace with that.</p>

<p>Then this year it woke up. In the last few weeks I have shipped more posts
than in the previous three years combined. The thing that changed was not
discipline or a new year resolution. It was figuring out how to use AI to
remove the parts of writing that kept stopping me.</p>

<h2 id="what-actually-kept-me-from-writing">What actually kept me from writing</h2>

<p>It was never the ideas. I always had things I wanted to write about: a
homelab fix, a transcoding tweak, a tmux habit, something I learned the
hard way. The ideas pile up. What I did not have was the runway between
“I know this” and “this is a finished post someone can read.”</p>

<p>That runway is full of small frictions. Staring at a blank file. Deciding
how to structure something I understand in my head but have never put in
order. Second-guessing whether my English reads cleanly, since it is not
my first language. Doing the boring last mile of fixing a code block,
checking a command, tidying a front matter block. None of these are hard.
All of them are enough to make me close the tab and do something else.</p>

<p>A finished post was never one big task. It was twenty tiny ones, and any
of them could be the one that made me quit for the night.</p>

<p>If you read my old posts you can see this plainly. They are full of
grammar mistakes and misspellings, the kind that slip past you when
English is not your first language. I would publish something, then notice
a typo days later and go back to fix it, then notice another one, and
another. It was a slow drip of small corrections, and honestly it was
frustrating. Not enough to make me stop, but enough to take some of the
joy out of hitting publish.</p>

<h2 id="where-ai-fits-and-where-it-does-not">Where AI fits, and where it does not</h2>

<p>The trap is to think AI writes the post for you. It does not, and you do
not want it to. A post that is fully generated reads like nobody was home,
and the whole point of a personal blog is that someone is home. The voice
is the value.</p>

<p>So I do not ask it to write. I ask it to clear the runway.</p>

<p>I talk through an idea out loud and have it pull the shape out of my
rambling, so I start from an outline instead of an empty file. I write the
ugly first draft myself, in my own words, and then ask where it drags or
where I left a gap a reader would trip on. I lean on it to catch the
grammar mistakes I will always make as a non-native speaker, without
letting it sand my sentences into the same smooth paste every AI tool
reaches for. I have it sanity check a command or a code block before I
publish something wrong. And when a post is done, I lean on the workflow I
already wrote about, where a <a href="/2026/06/01/automating-my-busywork-with-ai-agents.html">scheduled agent</a>
handles the mechanical parts of getting words from my head to the site.</p>

<p>The judgment stays with me. What to write, what I actually think, which
opinions are mine to defend. AI just removes the friction between me and
that judgment. Same trick I use for everything else: automate the
gathering and the drafting, keep the thinking.</p>

<h2 id="keeping-it-sounding-like-me">Keeping it sounding like me</h2>

<p>This only works if the output still sounds like a person, and left alone
these tools do not. They reach for em dashes, for “in summary”, for the
same three transition words, for a confident flatness that belongs to no
one. So I push the opposite into every prompt. Write plainly. No em
dashes. Short words over long ones. If a sentence sounds like a press
release, cut it.</p>

<p>I also read every line before it ships. If a paragraph does not sound like
something I would actually say out loud, it does not go in. That is the
real filter. The AI can hand me ten versions of a sentence, but I am the
one who knows which one is mine.</p>

<h2 id="was-it-worth-it">Was it worth it</h2>

<p>The honest measure is not quality, it is volume of finished work. Posts
that used to die at eighty percent now cross the line. The backlog of “I
should write that down” is finally shrinking instead of growing. And the
posts still sound like me, because the part that is me is the part I never
handed off.</p>

<p>If you have a blog asleep somewhere, this is the unglamorous advice:
do not ask AI to write for you. Ask it to remove whatever specific thing
keeps stopping you. For me that was the blank page, the structure, and the
grammar I was self-conscious about. Once those were gone, it turned out I
had plenty to say.</p>

<p>The blog is awake again. Let us see how long I can keep it that way.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="writing" /><category term="ai" /><category term="ai" /><category term="writing" /><category term="blogging" /><category term="jekyll" /><category term="habits" /><summary type="html"><![CDATA[I have had this blog for over a decade. If you scroll far enough back you will find Portuguese posts about Objective-C and cocos2d from 2010, a scatter of...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why You Should Sign Your Git Commits With GPG</title><link href="https://ferbass.xyz/2026/06/16/why-sign-commits-with-gpg.html" rel="alternate" type="text/html" title="Why You Should Sign Your Git Commits With GPG" /><published>2026-06-16T00:00:00+09:00</published><updated>2026-06-16T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/16/why-sign-commits-with-gpg</id><content type="html" xml:base="https://ferbass.xyz/2026/06/16/why-sign-commits-with-gpg.html"><![CDATA[<p>Here is a thing most developers skip: signing git commits with a GPG key. It
is one of those “you only notice it when it breaks” problems, so I want to
make the case for it and show how to set it up in about ten minutes.</p>

<h2 id="the-problem-with-unsigned-commits">The problem with unsigned commits</h2>

<p>Git trusts whatever you put in <code class="language-plaintext highlighter-rouge">user.name</code> and <code class="language-plaintext highlighter-rouge">user.email</code>. That is it.
There is no verification. Anyone with push access — or who briefly gets it —
can write commits that claim to be from you. An attacker who compromises an
account, a CI bot misconfigured, even a curious contractor with too much
access can all author commits under your name. Git will not blink.</p>

<p>Signing a commit ties it to a GPG private key only you hold. GitHub shows a
green <strong>Verified</strong> badge on every signed commit. Unverified commits still work,
but they are a yellow flag in a security audit. And some repos enforce it:
with “require signed commits” on in branch protection, unsigned pushes are
rejected outright.</p>

<p>The practical reasons are:</p>

<ul>
  <li><strong>Traceability</strong>: you can prove, cryptographically, that a release contains
only commits made by known developers.</li>
  <li><strong>Non-repudiation</strong>: a signed commit cannot be plausibly disowned.</li>
  <li><strong>Consistent protection rules</strong>: one environment with approval requirements
is easier to manage than scattered workarounds. Same idea: centralize trust,
enforce it in one place.</li>
</ul>

<h2 id="setting-it-up">Setting it up</h2>

<p>Install <code class="language-plaintext highlighter-rouge">gnupg</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>gnupg
</code></pre></div></div>

<p>Generate a key — RSA 4096 is a safe default, just follow the prompts:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg <span class="nt">--full-generate-key</span>
</code></pre></div></div>

<p>Find your key ID:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg <span class="nt">--list-secret-keys</span> <span class="nt">--keyid-format</span> LONG
<span class="c"># sec   rsa4096/ABCD1234EFGH5678 2026-06-16 [SC]</span>
</code></pre></div></div>

<p>The long hex after <code class="language-plaintext highlighter-rouge">rsa4096/</code> is your key ID. Tell git to use it and to sign
every commit automatically:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git config <span class="nt">--global</span> user.signingkey ABCD1234EFGH5678
git config <span class="nt">--global</span> commit.gpgsign <span class="nb">true</span>
</code></pre></div></div>

<p>Export your public key and add it to GitHub under
<strong>Settings → SSH and GPG keys → New GPG key</strong>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg <span class="nt">--armor</span> <span class="nt">--export</span> ABCD1234EFGH5678 | pbcopy
</code></pre></div></div>

<p>Your next <code class="language-plaintext highlighter-rouge">git commit</code> will prompt for your passphrase in the terminal and
GitHub will show the Verified badge from then on.</p>

<h2 id="to-lock-or-not-to-lock">To lock or not to lock</h2>

<p>When you generate the key, GPG asks for a passphrase. You have three real options:</p>

<p><strong>No passphrase</strong>: the key is protected only by filesystem permissions. Fast,
zero friction, and fine if your machine is encrypted and you are the only user.
The risk is that anyone who gets access to your disk (stolen laptop, compromised
account) can sign commits as you with no additional barrier.</p>

<p><strong>Passphrase, cached forever</strong>: you type it once after boot and the agent holds
it until you restart. This is what most people end up with. You get the security
benefit of the passphrase existing without the daily friction of typing it.</p>

<p><strong>Passphrase, cached for N seconds</strong>: the agent forgets the passphrase after a
timeout and asks again. This is the right choice if you step away from your
machine often or work in shared environments.</p>

<h2 id="setting-the-cache-timeout">Setting the cache timeout</h2>

<p>The gpg-agent controls how long a passphrase stays cached. Edit (or create)
<code class="language-plaintext highlighter-rouge">~/.gnupg/gpg-agent.conf</code>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>default-cache-ttl 3600
max-cache-ttl 86400
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">default-cache-ttl</code> is how long after the last use before the agent forgets
the passphrase (3600 means one hour of inactivity). <code class="language-plaintext highlighter-rouge">max-cache-ttl</code> is the
hard ceiling regardless of activity (86400 is one day). After that the agent
always asks again, even if you have been committing non-stop.</p>

<p>Apply the change without restarting:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpgconf <span class="nt">--kill</span> gpg-agent
</code></pre></div></div>

<p>The agent restarts automatically on the next use. If you want it gone
immediately until next prompt, you can also clear the cache manually:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg-connect-agent reloadagent /bye
</code></pre></div></div>

<p>My setup is one hour idle, one day hard limit. I commit in bursts and I do not
want to type my passphrase every ten minutes, but I also do not want a long
unattended session to silently sign things without me knowing. That balance
feels right. If you are on a shared or remote machine, tighten it down.</p>

<h2 id="the-amend-escape-hatch">The amend escape hatch</h2>

<p>If a commit slips through unsigned — an agent that was not running, a
non-interactive shell — you can retroactively sign it and re-push:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git commit <span class="nt">--amend</span> <span class="nt">--no-edit</span> <span class="nt">-S</span>
git push <span class="nt">--force-with-lease</span>
</code></pre></div></div>

<p>Minor friction, worth the tradeoff for knowing every commit with your name
actually came from you.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="tools" /><category term="security" /><category term="git" /><category term="gpg" /><category term="security" /><category term="github" /><category term="macos" /><summary type="html"><![CDATA[Here is a thing most developers skip: signing git commits with a GPG key. It is one of those "you only notice it when it breaks" problems, so I want to make...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Manipulating Images From the Command Line</title><link href="https://ferbass.xyz/2026/06/12/manipulating-images-from-the-command-line.html" rel="alternate" type="text/html" title="Manipulating Images From the Command Line" /><published>2026-06-12T00:00:00+09:00</published><updated>2026-06-12T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/12/manipulating-images-from-the-command-line</id><content type="html" xml:base="https://ferbass.xyz/2026/06/12/manipulating-images-from-the-command-line.html"><![CDATA[<p>I do have Pixelmator on my Mac, but I am not much of an image-editor person.
I can find my way around it when I have to, yet most of the image work I
actually do is boring and repetitive: resize this, crop that, shave a few
hundred kilobytes off a file, turn an SVG into a PNG. None of that needs a
mouse, and sometimes you can be faster without a mouse if you know what you
are doing. All of it can be a one line command you can rerun, script, and
remember.</p>

<p>This post is a tour of the tools I reach for, built around a real example
from last week: I had a nice app icon with an AI watermark stuck in the
corner, and I wanted it gone without opening an editor.</p>

<h2 id="the-two-tools">The two tools</h2>

<p>You only need two things, and on a Mac you already have one of them.</p>

<ul>
  <li><strong><a href="https://imagemagick.org/">ImageMagick</a></strong> is the swiss army knife. The
modern command is <code class="language-plaintext highlighter-rouge">magick</code> (older systems call it <code class="language-plaintext highlighter-rouge">convert</code>). Install it
with <code class="language-plaintext highlighter-rouge">brew install imagemagick</code>.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">sips</code></strong> (Scriptable Image Processing System) ships with macOS. It is
less powerful than ImageMagick but it is always there, which makes it
perfect for quick resizes inside a script you do not want to add
dependencies to.</li>
</ul>

<p>I mix them freely. The rule of thumb: <code class="language-plaintext highlighter-rouge">sips</code> for a quick resize, ImageMagick
for anything that involves cropping, compositing, or thinking.</p>

<h2 id="looking-before-you-leap">Looking before you leap</h2>

<p>Before touching an image, find out what it is. Width, height, format:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ImageMagick</span>
magick identify butterfly.png
<span class="c"># butterfly.png PNG 1024x1024 1024x1024+0+0 8-bit sRGB 1097747B</span>

<span class="c"># sips, on macOS</span>
sips <span class="nt">-g</span> pixelWidth <span class="nt">-g</span> pixelHeight butterfly.png
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">1097747B</code> at the end is the file size in bytes — just over 1 MB,
which already tells me this image is too heavy for what it is.</p>

<h2 id="resizing">Resizing</h2>

<p>The single most common operation. Scale the longest side to 600 pixels and
keep the aspect ratio:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># ImageMagick: the &gt; means "only shrink, never enlarge"</span>
magick butterfly.png <span class="nt">-resize</span> <span class="s1">'600x600&gt;'</span> butterfly-600.png

<span class="c"># sips: -Z resizes the longest edge, preserving aspect ratio</span>
sips <span class="nt">-Z</span> 600 butterfly.png <span class="nt">--out</span> butterfly-600.png
</code></pre></div></div>

<p>A small trick worth knowing: if you are downscaling something with fine
detail, render or resize <em>bigger</em> than you need and then scale down. The
extra pixels give the resampler more to average from, so edges come out
cleaner. I do this constantly with anything destined for a share card.</p>

<h2 id="chasing-a-file-size">Chasing a file size</h2>

<p>Here is a problem I actually hit. I wanted this icon to show up as the
preview image when the page is shared on WhatsApp. WhatsApp is famously
fussy and tends to ignore preview images much over ~300 KB. My PNG was
1 MB.</p>

<p>The fix is mostly resolution. A share thumbnail does not need to be 1024px:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Drop to 480px and check the new size in one line</span>
sips <span class="nt">-Z</span> 480 icon.png <span class="nt">--out</span> icon.png
magick identify <span class="nt">-format</span> <span class="s1">'%B bytes\n'</span> icon.png
<span class="c"># 267516 bytes</span>
</code></pre></div></div>

<p>That alone took it from 1 MB to 260 KB — comfortably under the limit, with
no visible quality loss at the size it is actually displayed. If you need to
squeeze further without resizing, <code class="language-plaintext highlighter-rouge">pngquant --quality=65-90 in.png</code> does
excellent lossy palette compression on PNGs, and for photos a JPEG at
<code class="language-plaintext highlighter-rouge">-quality 82</code> is usually indistinguishable from the original at a fraction
of the size.</p>

<h2 id="cropping-and-using-it-to-inspect">Cropping, and using it to inspect</h2>

<p><code class="language-plaintext highlighter-rouge">-crop WxH+X+Y</code> cuts a rectangle: width by height, offset X pixels from the
left and Y from the top. The <code class="language-plaintext highlighter-rouge">+repage</code> afterwards resets the virtual canvas
so the crop becomes a clean standalone image.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Pull out a 260x260 square starting at (764, 764) — the bottom-right corner</span>
magick butterfly.png <span class="nt">-crop</span> 260x260+764+764 +repage corner.png
</code></pre></div></div>

<p>I use crop not just to produce final images but to <em>look</em> at things. When I
suspected there was a watermark in the corner, I cropped that corner out and
blew it up so I could see exactly where it was and how big:</p>

<p><img src="/img/posts/2026-06-12-manipulating-images-from-the-command-line/corner-before.png" alt="Zoomed bottom-right corner showing the AI watermark" /></p>

<p>There it is — the little four-point sparkle an image generator stamps in the
corner.</p>

<h2 id="the-interesting-part-removing-the-watermark">The interesting part: removing the watermark</h2>

<p>Here is the icon, watermark and all:</p>

<p><img src="/img/posts/2026-06-12-manipulating-images-from-the-command-line/butterfly-watermark.png" alt="App icon with an AI watermark in the bottom-right corner" /></p>

<p>The naive approach is to paint a white box over the sparkle. That fails the
moment the background is not flat — and this one has a subtle grey gradient
toward the edges, so a white patch leaves an obvious bright square.</p>

<p>My first real attempt was to <strong>clone a clean patch</strong> from just above the
sparkle and composite it on top:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>magick icon.png <span class="se">\</span>
  <span class="se">\(</span> <span class="nt">-clone</span> 0 <span class="nt">-crop</span> 140x140+884+744 +repage <span class="se">\)</span> <span class="se">\</span>
  <span class="nt">-geometry</span> +884+884 <span class="nt">-compose</span> over <span class="nt">-composite</span> cleaned.png
</code></pre></div></div>

<p>Closer, but the patch I copied came from higher up where the gradient is a
shade lighter, so it <em>also</em> left a faint square. The background varies
vertically, so copying vertically can never match.</p>

<p>The trick that actually worked: the gradient is <strong>symmetric left to right</strong>,
so the clean bottom-<em>left</em> corner is the perfect donor. Copy it, flip it
horizontally with <code class="language-plaintext highlighter-rouge">-flop</code>, and drop it over the bottom-right:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>magick icon.png <span class="se">\</span>
  <span class="se">\(</span> <span class="nt">-clone</span> 0 <span class="nt">-crop</span> 140x140+0+884 +repage <span class="nt">-flop</span> <span class="se">\)</span> <span class="se">\</span>
  <span class="nt">-geometry</span> +884+884 <span class="nt">-compose</span> over <span class="nt">-composite</span> cleaned.png
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-flop</code> mirrors horizontally (<code class="language-plaintext highlighter-rouge">-flip</code> would mirror vertically). Because the
donor is the mirror image of where it lands, the gradient lines up exactly
and the seam disappears:</p>

<p><img src="/img/posts/2026-06-12-manipulating-images-from-the-command-line/butterfly-clean.png" alt="The same icon with the watermark cleanly removed" /></p>

<p>And the corner, before and after, side by side — the sparkle is gone and you
cannot tell anything was touched:</p>

<div style="display: flex; gap: 8px; flex-wrap: wrap;">
  <img src="/img/posts/2026-06-12-manipulating-images-from-the-command-line/corner-before.png" alt="Corner with the watermark" style="max-width: 48%; height: auto;" />
  <img src="/img/posts/2026-06-12-manipulating-images-from-the-command-line/corner-after.png" alt="Corner after removing the watermark" style="max-width: 48%; height: auto;" />
</div>

<p>The lesson is less about ImageMagick and more about the approach: when you
patch over something, steal from the place whose background already matches.
For a symmetric backdrop, the mirror is free.</p>

<h2 id="bonus-turning-an-svg-into-a-png">Bonus: turning an SVG into a PNG</h2>

<p>The other thing I did that week was take an SVG favicon and produce a raster
share image from it. SVGs are vectors, so you can render them at any size —
the move is to rasterise at high density and scale down for clean edges:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>magick <span class="nt">-background</span> none <span class="nt">-density</span> 1200 favicon.svg <span class="nt">-resize</span> 1200x1200 robot.png
</code></pre></div></div>

<p>Then I gave it some breathing room by centring it on a dark card. <code class="language-plaintext highlighter-rouge">-extent</code>
grows the canvas to a fixed size and <code class="language-plaintext highlighter-rouge">-gravity center</code> keeps the subject in
the middle:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>magick robot.png <span class="nt">-resize</span> 470x470 <span class="se">\</span>
  <span class="nt">-background</span> <span class="s2">"#2b2b2b"</span> <span class="nt">-gravity</span> center <span class="nt">-extent</span> 600x600 card.png
</code></pre></div></div>

<p>That is the whole pipeline that produced the share image for this very blog,
in two commands and no editor.</p>

<h2 id="why-bother">Why bother</h2>

<p>Because it is repeatable. Once a command works, it is in my shell history
forever, it goes into scripts, and it runs the same on my laptop as it does
in CI. The first time you figure out the incantation it feels slower than
dragging sliders. Every time after that, it is a single line you paste and
forget. For the boring 90% of image work, that is exactly the trade I want.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="tools" /><category term="productivity" /><category term="imagemagick" /><category term="cli" /><category term="macos" /><category term="images" /><category term="automation" /><summary type="html"><![CDATA[I do have Pixelmator on my Mac, but I am not much of an image-editor person. I can find my way around it when I have to, yet most of the image work I actually...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Reviving a 2012 iOS App with Machine Learning: From SplashCam to Splash Image</title><link href="https://ferbass.xyz/2026/06/10/reviving-splashcam.html" rel="alternate" type="text/html" title="Reviving a 2012 iOS App with Machine Learning: From SplashCam to Splash Image" /><published>2026-06-10T00:00:00+09:00</published><updated>2026-06-10T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/10/reviving-splashcam</id><content type="html" xml:base="https://ferbass.xyz/2026/06/10/reviving-splashcam.html"><![CDATA[<div style="display: flex; align-items: center; justify-content: center; gap: 1.5rem; margin: 2rem 0;">
  <img src="/img/posts/2026-05-21-reviving-splashcam/old-icon.png" alt="Original SplashCam app icon" width="120" />
  <span style="font-size: 2.5rem; line-height: 1;" aria-hidden="true">→</span>
  <img src="/img/posts/2026-05-21-reviving-splashcam/new-icon-white.jpg" alt="New Splash Image app icon" width="120" />
</div>

<p style="text-align: center; font-size: 0.85rem; opacity: 0.7; margin-top: -0.5rem;"><em>Original icon designed by a friend back in 2012. New icon created with the help of AI.</em></p>

<div style="text-align: center; margin: 2rem 0;">
  <a href="https://apps.apple.com/app/splash-image/id6771215193" style="display: inline-flex; align-items: center; gap: 0.6rem; padding: 0.7rem 1.4rem; border: 1px solid currentColor; border-radius: 12px; text-decoration: none; font-weight: 600;">
     Download on the App Store
  </a>
  <p style="font-size: 0.85rem; opacity: 0.7; margin-top: 0.5rem;"><em>Splash Image is now available on the App Store.</em></p>
</div>

<p>In 2012 I shipped my first App Store app: <strong>SplashCam</strong>, a camera app that applied a real-time colour splash effect, turning everything grey except a chosen hue. It worked, people used it, and then it quietly sat in a drawer for over a decade while iOS changed around it.</p>

<p>Earlier this year I decided to bring it back, but not just fix it. I wanted to rethink it. The live camera approach got scrapped in favour of a photo editor powered by Apple’s on-device ML, and the result can do something the original never could: understand <strong>what</strong> is in the photo, not just what colour it is.</p>

<p>A quick disclosure before going further: I started this revival on my own, but at some point I began leaning on AI to speed up the mechanical parts. Translating deprecated API calls, scaffolding test cases, looking up Vision and CoreImage signatures I hadn’t touched before. The architecture, the decisions about what to keep and what to cut, the shader work and the detection pipeline are mine. AI mostly made the typing faster.</p>

<p>One more thing worth mentioning upfront: I kept <strong>Objective-C</strong>. The codebase is from 2012, most of the original structure was still worth preserving, and I genuinely like the language. What changed is everything around it. The old third-party frameworks are gone, modern APIs replace every deprecated call, and the new ML pipeline uses the same Vision and CoreImage headers you’d reach for in a fresh Swift project. It feels current because the <em>APIs</em> are current, not because it was rewritten for its own sake.</p>

<hr />

<h2 id="what-the-original-looked-like">What the Original Looked Like</h2>

<p><img src="/img/posts/2026-05-21-reviving-splashcam/IMG_0590.png" alt="SplashCam in 2012, running on iOS" /></p>

<p>The project was started in April 2012, back when <code class="language-plaintext highlighter-rouge">UIAlertView</code> was still the way you showed a dialog and <code class="language-plaintext highlighter-rouge">@synthesize</code> was something you typed on every property.</p>

<h3 id="the-colour-splash-effect">The colour splash effect</h3>

<p>The core trick was a GLSL fragment shader running on OpenGL ES. Given a camera frame as a texture, it computed the hue of every pixel and compared it against up to <strong>six</strong> target hues. Pixels whose hue fell within a threshold kept their colour; everything else was pushed to greyscale using the classic luminance formula (<code class="language-plaintext highlighter-rouge">0.30R + 0.59G + 0.11B</code>).</p>

<div class="language-glsl highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// SplashColor.fsh (2012) - OpenGL ES fragment shader</span>
<span class="k">precision</span> <span class="kt">highp</span> <span class="kt">float</span><span class="p">;</span>
<span class="k">uniform</span> <span class="kt">sampler2D</span> <span class="n">s_texture</span><span class="p">;</span>
<span class="k">uniform</span> <span class="kt">float</span> <span class="n">target_hue</span><span class="p">[</span><span class="mi">6</span><span class="p">];</span>
<span class="k">uniform</span> <span class="kt">float</span> <span class="n">threshold</span><span class="p">[</span><span class="mi">6</span><span class="p">];</span>
<span class="k">varying</span> <span class="kt">vec2</span> <span class="n">v_texCoord</span><span class="p">;</span>

<span class="kt">void</span> <span class="nf">main</span><span class="p">()</span> <span class="p">{</span>
    <span class="kt">vec4</span> <span class="n">color</span> <span class="o">=</span> <span class="n">texture2D</span><span class="p">(</span><span class="n">s_texture</span><span class="p">,</span> <span class="n">v_texCoord</span><span class="p">);</span>
    <span class="kt">float</span> <span class="n">hue</span> <span class="o">=</span> <span class="n">atan2</span><span class="p">(</span><span class="n">sqrt3</span><span class="o">*</span><span class="p">(</span><span class="n">color</span><span class="p">.</span><span class="n">g</span> <span class="o">-</span> <span class="n">color</span><span class="p">.</span><span class="n">b</span><span class="p">),</span>
                      <span class="n">dot</span><span class="p">(</span><span class="n">color</span><span class="p">.</span><span class="n">rgb</span><span class="p">,</span> <span class="kt">vec3</span><span class="p">(</span><span class="mi">2</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">)));</span>
    <span class="kt">float</span> <span class="n">br</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">;</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">6</span><span class="p">;</span> <span class="o">++</span><span class="n">i</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">float</span> <span class="n">dHue</span> <span class="o">=</span> <span class="n">abs</span><span class="p">(</span><span class="n">hue</span> <span class="o">-</span> <span class="n">target_hue</span><span class="p">[</span><span class="n">i</span><span class="p">]);</span>
        <span class="n">dHue</span> <span class="o">=</span> <span class="n">min</span><span class="p">(</span><span class="n">dHue</span><span class="p">,</span> <span class="mi">2</span><span class="p">.</span><span class="mi">0</span><span class="o">*</span><span class="n">pi</span> <span class="o">-</span> <span class="n">dHue</span><span class="p">);</span>
        <span class="n">br</span> <span class="o">+=</span> <span class="n">clamp</span><span class="p">(</span><span class="n">threshold</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="o">-</span> <span class="n">dHue</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="n">br</span> <span class="o">=</span> <span class="n">clamp</span><span class="p">(</span><span class="n">br</span> <span class="o">*</span> <span class="mi">100</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">);</span>
    <span class="kt">float</span> <span class="n">gray</span> <span class="o">=</span> <span class="n">dot</span><span class="p">(</span><span class="n">color</span><span class="p">.</span><span class="n">rgb</span><span class="p">,</span> <span class="kt">vec3</span><span class="p">(</span><span class="mi">0</span><span class="p">.</span><span class="mi">3</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mi">59</span><span class="p">,</span> <span class="mi">0</span><span class="p">.</span><span class="mi">11</span><span class="p">));</span>
    <span class="nb">gl_FragColor</span> <span class="o">=</span> <span class="n">mix</span><span class="p">(</span><span class="kt">vec4</span><span class="p">(</span><span class="n">gray</span><span class="p">),</span> <span class="n">color</span><span class="p">,</span> <span class="n">br</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The rendering layer was <strong>XBImageFilters</strong>, an OpenGL ES camera filtering framework written by a friend of mine (<a href="https://github.com/xissburg">xissburg</a>). The framework was bundled directly in the repo as a compiled binary, alongside Crashlytics and Flurry. Dependency management in 2012.</p>

<h3 id="what-else-was-in-there">What else was in there</h3>

<ul>
  <li><strong>ShareViewController</strong>: a custom share screen for posting to <strong>Facebook</strong> (bundled <code class="language-plaintext highlighter-rouge">SCFacebook.m</code> wrapping the old Facebook iOS SDK) and <strong>Twitter</strong> (via <code class="language-plaintext highlighter-rouge">MGTwitterEngine</code> + OAuth, another ~30 files committed to the repo)</li>
  <li><strong>FlurryAnalytics</strong> and <strong>Crashlytics</strong>: both as static <code class="language-plaintext highlighter-rouge">.a</code> blobs or binary frameworks</li>
  <li><strong>ColorRangeGroupView</strong>: a custom colour picker with swipe and pan gesture recognisers, wired up through IBOutlets in a storyboard</li>
  <li>The entire camera, shader, gesture handling, colour state, and share flow lived in one <code class="language-plaintext highlighter-rouge">SplashCamViewController</code>, around 1 300 lines</li>
</ul>

<h3 id="the-fundamental-limitation">The fundamental limitation</h3>

<p>The hue approach works well for something vivid and isolated, like a red dress against a neutral background. But it breaks down fast. A yellow sunset and a yellow taxi share the same hue. A green tree and a green jacket are indistinguishable to the shader. The effect was cute, but you were always fighting the physics of colour rather than selecting the <em>thing</em> you actually cared about.</p>

<hr />

<h2 id="what-had-to-go-first">What Had to Go First</h2>

<p>Before any new features, the project needed to compile on a modern SDK. That meant:</p>

<ul>
  <li><strong>Removing OpenGL ES</strong>: deprecated since iOS 12 and gone from simulators</li>
  <li><strong>Ripping out all the bundled frameworks</strong>: Facebook SDK, Twitter+OAuth, MGTwitterEngine, Flurry, Crashlytics, XBImageFilters (~15 000 lines of third-party code deleted)</li>
  <li><strong>Replacing UIAlertView, MediaPlayer, and other deprecated APIs</strong> with their modern counterparts</li>
  <li><strong>Switching from a storyboard-driven flow to a programmatic one</strong> using a modern <code class="language-plaintext highlighter-rouge">AppDelegate</code> and <code class="language-plaintext highlighter-rouge">UIButtonConfiguration</code></li>
</ul>

<p>The code stayed in Objective-C throughout. Modern Objective-C with full nullability annotations (<code class="language-plaintext highlighter-rouge">NS_ASSUME_NONNULL_BEGIN</code>, <code class="language-plaintext highlighter-rouge">_Nullable</code>, <code class="language-plaintext highlighter-rouge">_Nonnull</code>), <code class="language-plaintext highlighter-rouge">@available</code> checks, <code class="language-plaintext highlighter-rouge">NS_UNAVAILABLE</code> on initialisers that shouldn’t be called. It reads nothing like the 2012 original, even though it compiles to the same language.</p>

<hr />

<h2 id="the-pivot-drop-the-live-camera-use-ml-instead">The Pivot: Drop the Live Camera, Use ML Instead</h2>

<p>Once the codebase was clean, I started rebuilding the camera: Metal shader, AVFoundation pipeline, a logarithmic zoom wheel modelled on Apple Camera, AE/AF lock, macro mode on supported devices. It all worked. And then I decided to throw most of it out.</p>

<p>The live camera filter had the same problem as the original: it was still hue-based. You could splash red, green, or blue in real time, but you still couldn’t say “keep the dog in colour, grey out everything else.” The effect is most compelling when it acts on a recognisable <em>subject</em>, not an arbitrary band of the colour wheel.</p>

<p>Apple’s <strong>Vision</strong> framework gained <code class="language-plaintext highlighter-rouge">VNGenerateForegroundInstanceMaskRequest</code> in iOS 17. It uses an on-device ML model to detect every distinct foreground subject in an image and return a per-pixel mask for each independently: person, animal, object, whatever the model can segment. That changes the question from “what hue do you want?” to “which thing do you want?”</p>

<p>So the app became a <strong>photo editor</strong>. The home screen has two buttons:</p>

<ul>
  <li><strong>Take Photo</strong>: opens the system camera, captures the photo, and immediately hands it to the editor</li>
  <li><strong>Choose from Library</strong>: opens <code class="language-plaintext highlighter-rouge">PHPickerViewController</code> and does the same</li>
</ul>

<p>Neither path shows a live filtered preview. Both feed a still image into <code class="language-plaintext highlighter-rouge">SCGalleryEditorViewController</code> and let the ML do the work.</p>

<div style="display: flex; align-items: flex-start; justify-content: center; gap: 1rem; margin: 2rem 0; flex-wrap: wrap;">
  <img src="/img/posts/2026-05-21-reviving-splashcam/new-ui-1.png" alt="Splash Image editor with a subject selected" style="max-width: 48%; height: auto;" />
  <img src="/img/posts/2026-05-21-reviving-splashcam/new-ui-2.png" alt="Splash Image editor showing the thumbnail strip of detected instances" style="max-width: 48%; height: auto;" />
</div>

<hr />

<h2 id="the-detection-pipeline">The Detection Pipeline</h2>

<p>The interesting part is in <code class="language-plaintext highlighter-rouge">SCSubjectSplashProcessor</code>, which turns a <code class="language-plaintext highlighter-rouge">CIImage</code> into a set of independently selectable per-subject masks. It runs a sequence of Vision requests, each covering a different class of subject (a fifth, objectness-based stage was added during the pre-launch polish described later).</p>

<h3 id="stage-1-person-segmentation-ios-15">Stage 1: Person segmentation (iOS 15+)</h3>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">VNGeneratePersonSegmentationRequest</span> <span class="o">*</span><span class="n">personReq</span> <span class="o">=</span>
    <span class="p">[[</span><span class="n">VNGeneratePersonSegmentationRequest</span> <span class="nf">alloc</span><span class="p">]</span> <span class="nf">init</span><span class="p">];</span>
<span class="n">personReq</span><span class="p">.</span><span class="n">qualityLevel</span> <span class="o">=</span> <span class="n">VNGeneratePersonSegmentationRequestQualityLevelBalanced</span><span class="p">;</span>
<span class="n">personReq</span><span class="p">.</span><span class="n">outputPixelFormat</span> <span class="o">=</span> <span class="n">kCVPixelFormatType_OneComponent8</span><span class="p">;</span>
</code></pre></div></div>

<p>This runs first and its result isn’t immediately added to the instance list. Instead, it becomes a reference mask used in stage 2 to split mixed instances. It also acts as a standalone fallback: if the foreground instance detector misses a person, the person mask is added on its own after deduplication.</p>

<h3 id="stage-2-foreground-instance-segmentation-ios-17">Stage 2: Foreground instance segmentation (iOS 17+)</h3>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">VNGenerateForegroundInstanceMaskRequest</span> <span class="o">*</span><span class="n">fgReq</span> <span class="o">=</span>
    <span class="p">[[</span><span class="n">VNGenerateForegroundInstanceMaskRequest</span> <span class="nf">alloc</span><span class="p">]</span> <span class="nf">init</span><span class="p">];</span>
</code></pre></div></div>

<p>This is the primary detector. It returns <code class="language-plaintext highlighter-rouge">VNInstanceMaskObservation.allInstances</code>, an <code class="language-plaintext highlighter-rouge">NSIndexSet</code> of 1-based Vision instance indices. For each index, <code class="language-plaintext highlighter-rouge">generateScaledMaskForImageForInstances:fromRequestHandler:error:</code> produces a per-pixel <code class="language-plaintext highlighter-rouge">CVPixelBufferRef</code> at source resolution.</p>

<p>Each mask then passes through the person-split step.</p>

<h4 id="personobject-split">Person/object split</h4>

<p>A lot of interesting photos involve a person holding or interacting with something: a musician with an instrument, someone holding a flower. Vision’s instance detector often groups the person and the held object into a single mask. The person-split checks whether an instance mask straddles that boundary:</p>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">CGFloat</span> <span class="n">ratio</span> <span class="o">=</span> <span class="p">[</span><span class="n">self</span> <span class="nf">_areaOfMask</span><span class="p">:</span><span class="n">intersection</span><span class="p">]</span> <span class="o">/</span> <span class="n">instanceArea</span><span class="p">;</span>

<span class="k">static</span> <span class="k">const</span> <span class="n">CGFloat</span> <span class="n">kLowSplit</span>  <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">15</span><span class="p">;</span>
<span class="k">static</span> <span class="k">const</span> <span class="n">CGFloat</span> <span class="n">kHighSplit</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">85</span><span class="p">;</span>
<span class="k">if</span> <span class="p">(</span><span class="n">ratio</span> <span class="o">&lt;=</span> <span class="n">kLowSplit</span> <span class="o">||</span> <span class="n">ratio</span> <span class="o">&gt;=</span> <span class="n">kHighSplit</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">@[</span><span class="n">instanceMask</span><span class="p">];</span> <span class="c1">// clearly one thing, don't split</span>
<span class="p">}</span>

<span class="n">CIImage</span> <span class="o">*</span><span class="n">personPart</span>  <span class="o">=</span> <span class="p">[</span><span class="n">self</span> <span class="nf">_intersectMask</span><span class="p">:</span><span class="n">instanceMask</span> <span class="nf">withMask</span><span class="p">:</span><span class="n">alignedPerson</span><span class="p">];</span>
<span class="n">CIImage</span> <span class="o">*</span><span class="n">objectPart</span>  <span class="o">=</span> <span class="p">[</span><span class="n">self</span> <span class="nf">_subtractMask</span><span class="p">:</span><span class="n">instanceMask</span> <span class="nf">withMask</span><span class="p">:</span><span class="n">alignedPerson</span><span class="p">];</span>
</code></pre></div></div>

<p>If 15-85% of the instance overlaps the person mask, it’s treated as mixed and split into two pieces. Outside that window it’s clearly a whole person or a whole object and returned as-is. Pieces smaller than 0.3% of the source area are discarded as noise. The result is that “person holding phone” becomes two separately selectable items in the editor’s thumbnail strip.</p>

<p>The mask arithmetic uses CoreImage compositing filters:</p>
<ul>
  <li><strong>Intersection</strong> (<code class="language-plaintext highlighter-rouge">CIMultiplyCompositing</code>): treating both masks as luminance images, multiply is a soft AND</li>
  <li><strong>Subtraction</strong>: multiply A by the <code class="language-plaintext highlighter-rouge">CIColorInvert</code> of B to get A minus B</li>
</ul>

<h3 id="stage-3-animal-recognition-ios-13">Stage 3: Animal recognition (iOS 13+)</h3>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">VNRecognizeAnimalsRequest</span> <span class="o">*</span><span class="n">animalReq</span> <span class="o">=</span> <span class="p">[[</span><span class="n">VNRecognizeAnimalsRequest</span> <span class="nf">alloc</span><span class="p">]</span> <span class="nf">init</span><span class="p">];</span>
</code></pre></div></div>

<p>This returns <code class="language-plaintext highlighter-rouge">VNRecognizedObjectObservation</code> bounding boxes, not tight pixel masks. A bounding box around a cat that includes the sofa behind it is useless for compositing. So for each detected animal bounding box, the code crops the source image to that region, runs a fresh foreground instance scan on just that crop, then translates the resulting masks back to source coordinates:</p>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Crop to CI coordinate space, shift to origin, run instance detector,</span>
<span class="c1">// translate masks back to source position.</span>
<span class="n">CIImage</span> <span class="o">*</span><span class="n">cropped</span>  <span class="o">=</span> <span class="p">[</span><span class="n">source</span> <span class="nf">imageByCroppingToRect</span><span class="p">:</span><span class="n">ciRect</span><span class="p">];</span>
<span class="n">CIImage</span> <span class="o">*</span><span class="n">shifted</span>  <span class="o">=</span> <span class="p">[</span><span class="n">cropped</span> <span class="nf">imageByApplyingTransform</span><span class="p">:</span>
                        <span class="n">CGAffineTransformMakeTranslation</span><span class="p">(</span><span class="o">-</span><span class="n">ciRect</span><span class="p">.</span><span class="n">origin</span><span class="p">.</span><span class="n">x</span><span class="p">,</span>
                                                         <span class="o">-</span><span class="n">ciRect</span><span class="p">.</span><span class="n">origin</span><span class="p">.</span><span class="n">y</span><span class="p">)];</span>
<span class="c1">// ... run VNGenerateForegroundInstanceMaskRequest on `shifted` ...</span>
<span class="n">CIImage</span> <span class="o">*</span><span class="n">positioned</span> <span class="o">=</span> <span class="p">[</span><span class="n">mask</span> <span class="nf">imageByApplyingTransform</span><span class="p">:</span>
                           <span class="n">CGAffineTransformMakeTranslation</span><span class="p">(</span><span class="n">ciRect</span><span class="p">.</span><span class="n">origin</span><span class="p">.</span><span class="n">x</span><span class="p">,</span>
                                                            <span class="n">ciRect</span><span class="p">.</span><span class="n">origin</span><span class="p">.</span><span class="n">y</span><span class="p">)];</span>
</code></pre></div></div>

<p>This detect-then-refine approach gives the animal detection the same per-pixel mask quality as the foreground instance detector, without needing to have been explicitly trained to distinguish animal species.</p>

<h3 id="stage-4-saliency-fallback">Stage 4: Saliency fallback</h3>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">VNGenerateAttentionBasedSaliencyImageRequest</span> <span class="o">*</span><span class="n">salReq</span> <span class="o">=</span>
    <span class="p">[[</span><span class="n">VNGenerateAttentionBasedSaliencyImageRequest</span> <span class="nf">alloc</span><span class="p">]</span> <span class="nf">init</span><span class="p">];</span>
</code></pre></div></div>

<p>Only runs if stages 1-3 found nothing at all. The saliency model identifies regions a person would naturally look at, returning <code class="language-plaintext highlighter-rouge">salientObjects</code> as normalised bounding boxes. The same crop-and-refine approach from the animal stage produces tight masks from those boxes.</p>

<h3 id="deduplication-across-stages">Deduplication across stages</h3>

<p>Running four detectors on the same image risks returning the same subject multiple times. Every mask candidate is compared against everything already in the cache using <strong>IoU (intersection over union)</strong> computed on 64x64 downscaled probes:</p>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="p">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">64</span> <span class="o">*</span> <span class="mi">64</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">BOOL</span> <span class="n">inA</span> <span class="o">=</span> <span class="n">bufA</span><span class="p">[</span><span class="nf">i</span><span class="p">]</span> <span class="o">&gt;</span> <span class="mi">64</span><span class="p">;</span>
    <span class="n">BOOL</span> <span class="n">inB</span> <span class="o">=</span> <span class="n">bufB</span><span class="p">[</span><span class="nf">i</span><span class="p">]</span> <span class="o">&gt;</span> <span class="mi">64</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">inA</span> <span class="o">&amp;&amp;</span> <span class="n">inB</span><span class="p">)</span> <span class="n">intersection</span><span class="o">++</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">inA</span> <span class="o">||</span> <span class="n">inB</span><span class="p">)</span> <span class="n">unionCount</span><span class="o">++</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">return</span> <span class="p">(</span><span class="n">CGFloat</span><span class="p">)</span><span class="n">intersection</span> <span class="o">/</span> <span class="p">(</span><span class="n">CGFloat</span><span class="p">)</span><span class="n">unionCount</span><span class="p">;</span>
</code></pre></div></div>

<p>Any candidate with IoU &gt; 0.6 against an existing mask is dropped. The 64-pixel probe is intentionally coarse; it keeps the check cheap (a single <code class="language-plaintext highlighter-rouge">CGBitmapContextCreate</code> per mask) while still catching genuine duplicates.</p>

<p>The total instance list is capped (<strong>12</strong> after the pre-launch tuning below) regardless of how many stages fire, so group shots don’t blow up memory or clutter the thumbnail strip.</p>

<hr />

<h2 id="tap-to-select">Tap to Select</h2>

<p>When the user taps the image, the editor maps the tap point from UIKit’s top-left coordinate space to CoreImage’s bottom-left space and samples a single pixel from each cached mask:</p>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">CGFloat</span> <span class="n">ciY</span> <span class="o">=</span> <span class="n">floor</span><span class="p">(</span><span class="n">mask</span><span class="p">.</span><span class="n">extent</span><span class="p">.</span><span class="n">size</span><span class="p">.</span><span class="n">height</span> <span class="k">-</span> <span class="n">point</span><span class="p">.</span><span class="n">y</span> <span class="o">-</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">);</span>
<span class="n">CGRect</span> <span class="n">roi</span>  <span class="o">=</span> <span class="n">CGRectMake</span><span class="p">(</span><span class="n">floor</span><span class="p">(</span><span class="n">point</span><span class="p">.</span><span class="n">x</span><span class="p">),</span> <span class="n">ciY</span><span class="p">,</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1</span><span class="p">.</span><span class="mi">0</span><span class="p">);</span>
<span class="c1">// render 1x1 pixel from the mask -&gt; grey value -&gt; [0, 1]</span>
</code></pre></div></div>

<p>The mask with the highest value above 0.5 wins. Tapping the same subject again deselects it. Multiple subjects can be selected at once and their masks are unioned before compositing.</p>

<hr />

<h2 id="the-compositing-pipeline">The Compositing Pipeline</h2>

<p><code class="language-plaintext highlighter-rouge">renderCIImage</code> produces the final splash output as a <code class="language-plaintext highlighter-rouge">CIImage</code> without touching the GPU until something actually reads pixels:</p>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 1. Greyscale using the same luminance formula as the 2012 shader, via CIColorMatrix</span>
<span class="n">CIImage</span> <span class="o">*</span><span class="n">gray</span> <span class="o">=</span> <span class="p">[</span><span class="n">self</span> <span class="nf">grayscaleImageFromSource</span><span class="p">:</span><span class="n">source</span><span class="p">];</span>

<span class="c1">// 2. Union the selected instance masks</span>
<span class="n">CIImage</span> <span class="o">*</span><span class="n">combinedMask</span> <span class="o">=</span> <span class="p">[</span><span class="n">self</span> <span class="nf">combinedMaskForSelectedInstances</span><span class="p">];</span>

<span class="c1">// 3. Punch holes where the user has drawn exclusions:</span>
<span class="c1">//    finalMask = combinedMask x (1 - unionOfExclusions)</span>
<span class="k">if</span> <span class="p">(</span><span class="n">unionExclusion</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">CIImage</span> <span class="o">*</span><span class="n">inverted</span> <span class="o">=</span> <span class="p">[</span><span class="n">unionExclusion</span> <span class="nf">imageByApplyingFilter</span><span class="p">:</span><span class="s">@"CIColorInvert"</span><span class="p">];</span>
    <span class="n">CIFilter</span> <span class="o">*</span><span class="n">mult</span>    <span class="o">=</span> <span class="p">[</span><span class="n">CIFilter</span> <span class="nf">filterWithName</span><span class="p">:</span><span class="s">@"CIMultiplyCompositing"</span><span class="p">];</span>
    <span class="p">[</span><span class="n">mult</span> <span class="nf">setValue</span><span class="p">:</span><span class="n">combinedMask</span> <span class="nf">forKey</span><span class="p">:</span><span class="n">kCIInputImageKey</span><span class="p">];</span>
    <span class="p">[</span><span class="n">mult</span> <span class="nf">setValue</span><span class="p">:</span><span class="n">inverted</span>    <span class="nf">forKey</span><span class="p">:</span><span class="n">kCIInputBackgroundImageKey</span><span class="p">];</span>
    <span class="n">combinedMask</span> <span class="o">=</span> <span class="n">mult</span><span class="p">.</span><span class="n">outputImage</span><span class="p">;</span>
<span class="p">}</span>

<span class="c1">// 4. Blend: colour where mask is white, grey where mask is black</span>
<span class="n">CIFilter</span> <span class="o">*</span><span class="n">blend</span> <span class="o">=</span> <span class="p">[</span><span class="n">CIFilter</span> <span class="nf">filterWithName</span><span class="p">:</span><span class="s">@"CIBlendWithMask"</span><span class="p">];</span>
<span class="p">[</span><span class="n">blend</span> <span class="nf">setValue</span><span class="p">:</span><span class="n">source</span>       <span class="nf">forKey</span><span class="p">:</span><span class="n">kCIInputImageKey</span><span class="p">];</span>
<span class="p">[</span><span class="n">blend</span> <span class="nf">setValue</span><span class="p">:</span><span class="n">gray</span>          <span class="nf">forKey</span><span class="p">:</span><span class="n">kCIInputBackgroundImageKey</span><span class="p">];</span>
<span class="p">[</span><span class="n">blend</span> <span class="nf">setValue</span><span class="p">:</span><span class="n">combinedMask</span>  <span class="nf">forKey</span><span class="p">:</span><span class="n">kCIInputMaskImageKey</span><span class="p">];</span>
</code></pre></div></div>

<p>The greyscale step replicates the 2012 shader formula using <code class="language-plaintext highlighter-rouge">CIColorMatrix</code> with the same R/G/B weights, just expressed as CoreImage vectors instead of a GPU dot product. The visual feel of the effect is the same; only how the subject gets selected changed.</p>

<hr />

<h2 id="corrections-with-lasso-and-eraser">Corrections with Lasso and Eraser</h2>

<p>ML segmentation isn’t perfect. Edges bleed, similar-looking regions get merged, a bright shirt merges with the bright wall behind it. Two tools let the user fix this — one additive, one subtractive:</p>

<p><strong>Lasso</strong> (additive): circle a region the automatic pass missed and the editor re-runs detection scoped to that area, clipping the result to the outline you drew. It adds a new selectable subject rather than removing one. The details — and the bug where it used to select a rectangle — are in the pre-launch section below.</p>

<p><strong>Eraser</strong> (subtractive): paint strokes that accumulate into exclusion polygons on the fly via a <code class="language-plaintext highlighter-rouge">UIPanGestureRecognizer</code>. The editor converts each polygon to a <code class="language-plaintext highlighter-rouge">CIImage</code> mask at source resolution and appends it to the processor’s exclusion list. Those pixels get subtracted from the final composited mask via the <code class="language-plaintext highlighter-rouge">(1 - exclusion)</code> multiply step above.</p>

<p>Each exclusion is independently toggleable in the thumbnail strip, so you can turn one off to check whether it was actually needed before committing.</p>

<hr />

<h2 id="metadata-preservation">Metadata Preservation</h2>

<p>The old app discarded every byte of EXIF when saving. The new one reads the source image’s metadata via <code class="language-plaintext highlighter-rouge">CGImageSourceCopyPropertiesAtIndex</code>, merges it with the output JPEG, and only overwrites the fields that actually changed: orientation, pixel dimensions, and the software tag. GPS coordinates, camera make/model, and capture date survive the edit.</p>

<hr />

<h2 id="tooling">Tooling</h2>

<p>The 2012 project had no CI. You built locally, archived in Xcode, and uploaded through Application Loader.</p>

<p>The rewrite has:</p>

<ul>
  <li><strong>GitHub Actions</strong>: a <code class="language-plaintext highlighter-rouge">workflow_dispatch</code> pipeline (manual trigger) that runs on <code class="language-plaintext highlighter-rouge">macos-latest</code></li>
  <li><strong>Fastlane</strong> with <strong>Match</strong> for certificate and provisioning profile management via a private git repo</li>
  <li><strong>App Store Connect API key</strong> authentication with no Apple ID password in CI</li>
</ul>

<hr />

<h2 id="by-the-numbers">By the Numbers</h2>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>2012 (original)</th>
      <th>2026 (rewrite)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>App name</td>
      <td>SplashCam</td>
      <td>Splash Image</td>
    </tr>
    <tr>
      <td>Core workflow</td>
      <td>Live camera filter</td>
      <td>Photo editor</td>
    </tr>
    <tr>
      <td>Subject selection</td>
      <td>Hue threshold (R / G / B)</td>
      <td>Vision ML segmentation</td>
    </tr>
    <tr>
      <td>Render pipeline</td>
      <td>OpenGL ES + GLSL</td>
      <td>CoreImage + Vision</td>
    </tr>
    <tr>
      <td>Selection granularity</td>
      <td>Colour bands</td>
      <td>Semantic objects</td>
    </tr>
    <tr>
      <td>Detection stages</td>
      <td>1 (hue shader)</td>
      <td>5 (person, foreground, animal, objectness, saliency)</td>
    </tr>
    <tr>
      <td>Language</td>
      <td>Objective-C (2012 style)</td>
      <td>Objective-C (modern APIs)</td>
    </tr>
    <tr>
      <td>Social sharing</td>
      <td>Facebook + Twitter</td>
      <td>Removed</td>
    </tr>
    <tr>
      <td>Analytics</td>
      <td>Flurry + Crashlytics</td>
      <td>Removed</td>
    </tr>
    <tr>
      <td>Third-party code in repo</td>
      <td>~15 000 lines</td>
      <td>0 lines</td>
    </tr>
    <tr>
      <td>Unit tests</td>
      <td>0</td>
      <td>738 assertions across 2 logic suites + an on-device Vision suite</td>
    </tr>
    <tr>
      <td>CI/CD</td>
      <td>None</td>
      <td>GitHub Actions + Fastlane</td>
    </tr>
    <tr>
      <td>Min iOS (editor)</td>
      <td>iOS 6</td>
      <td>iOS 17</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="pre-launch-polish-hardening-the-detection">Pre-Launch Polish: Hardening the Detection</h2>

<p>The pipeline above worked, but the last pass before submitting to the App Store was about the cases where it <em>didn’t</em>. A handful of real photos exposed three rough edges, and fixing them turned out to be the most interesting work of the whole revival.</p>

<p>A dense test image made the problems obvious: a tray of ~40 apples, packed edge to edge, in five rows.</p>

<div style="display: flex; align-items: flex-start; justify-content: center; gap: 1rem; margin: 2rem 0; flex-wrap: wrap;">
  <img src="/img/posts/2026-05-21-reviving-splashcam/apples-original.jpg" alt="Original photo: a tray of about forty apples packed edge to edge" style="max-width: 48%; height: auto;" />
  <img src="/img/posts/2026-05-21-reviving-splashcam/apples-splash.jpg" alt="Splash result: a group of apples kept in colour while the rest of the tray is desaturated to grey" style="max-width: 48%; height: auto;" />
</div>

<p style="text-align: center; font-size: 0.85rem; opacity: 0.7; margin-top: -0.5rem;"><em>Left: the original. Right: the splash. A group of apples keeps its colour while the rest of the tray is cut cleanly from its neighbours and turned grey, with no rectangular blocks.</em></p>

<h3 id="problem-1-square-colour-blocks">Problem 1: square colour blocks</h3>

<p>The animal, saliency, and a newly-added <strong>objectness</strong> stage all share the same trick from Stage 3: take a bounding box, crop, and re-run foreground-instance segmentation to get a tight mask. But when Vision <em>can’t</em> isolate a subject inside the crop (busy scene, no clear figure/ground), it returns a mask that fills almost the entire rectangle. Composited, that reads as an ugly square block of colour.</p>

<p>The fix is a <strong>solidity guard</strong>: measure how much of its own bounding box a mask actually fills, and reject anything that behaves like a filled rectangle.</p>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// active pixels / area of the mask's own tight bounding box.</span>
<span class="c1">// A solid rectangle approaches 1.0; an inscribed circle ~0.79;</span>
<span class="c1">// an irregular subject is lower.</span>
<span class="k">-</span> <span class="p">(</span><span class="n">CGFloat</span><span class="p">)</span><span class="nf">_solidityOfMask</span><span class="p">:(</span><span class="n">CIImage</span> <span class="o">*</span><span class="p">)</span><span class="nv">mask</span> <span class="p">{</span> <span class="cm">/* 64x64 probe */</span> <span class="p">}</span>

<span class="k">static</span> <span class="k">const</span> <span class="n">CGFloat</span> <span class="n">kSCMaxMaskSolidity</span> <span class="o">=</span> <span class="mi">0</span><span class="p">.</span><span class="mi">90</span><span class="p">;</span>
<span class="c1">// ...inside the crop refiner:</span>
<span class="k">if</span> <span class="p">([</span><span class="n">self</span> <span class="nf">_solidityOfMask</span><span class="p">:</span><span class="n">positioned</span><span class="p">]</span> <span class="o">&gt;</span> <span class="n">kSCMaxMaskSolidity</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span> <span class="c1">// drop it</span>
</code></pre></div></div>

<p>A round apple fills roughly π/4 ≈ 0.79 of its bounding box and passes; a failed isolation sits near 1.0 and is dropped. The guard only applies to the box-based refinement stages — the primary foreground-instance detector is left untouched, so a genuinely rectangular subject (a book, a sign) still works.</p>

<h3 id="problem-2-subjects-that-touch-each-other">Problem 2: subjects that touch each other</h3>

<p><code class="language-plaintext highlighter-rouge">VNGenerateForegroundInstanceMaskRequest</code> segments <em>foreground vs background</em>, so objects of the same kind that touch — a row of apples — collapse into one blob. To recover them I added an <strong>objectness-based saliency</strong> stage (<code class="language-plaintext highlighter-rouge">VNGenerateObjectnessBasedSaliencyImageRequest</code>), which returns one bounding box per salient object. Running the crop-and-refine trick inside each box, then deduplicating, splits clusters the primary detector merged.</p>

<p>I also tried something more aggressive: a distance-transform watershed to cut a single large blob into one mask per object. It worked on paper but produced arbitrary, angular Voronoi chunks that didn’t line up with the fruit, so it got <strong>reverted</strong>. The honest limitation: with a hard cap on instances, a grid of forty identical touching objects can’t be cleanly separated into forty individual masks. But each detected subject now comes out cleanly cut from its neighbours — pick any of them and it splashes on its own, with sharp edges and no square block (above). For the realistic case — a few distinct subjects — objectness separation is plenty. The instance cap was raised from <strong>8 to 12</strong> to give clustered scenes more room.</p>

<h3 id="problem-3-the-lasso-selected-a-rectangle">Problem 3: the lasso selected a rectangle</h3>

<p>The manual lasso was supposed to let you re-scan a region the global pass missed, but it fed Vision the <em>bounding rectangle</em> of your stroke. Circle one apple in a dense grid and you’d get a square of its neighbours. The rework keeps the “force a fresh scan in this area” behaviour but clips the result to what you actually drew:</p>

<div class="language-objc highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// foreground detected in the circled area (existing instances</span>
<span class="c1">// unioned with a fresh crop re-scan) ... intersected with the</span>
<span class="c1">// rasterised lasso outline. Never the bounding rectangle.</span>
<span class="n">CIImage</span> <span class="o">*</span><span class="n">result</span> <span class="o">=</span> <span class="p">[</span><span class="n">self</span> <span class="nf">_intersectMask</span><span class="p">:</span><span class="n">drawnShape</span> <span class="nf">withMask</span><span class="p">:</span><span class="n">foreground</span><span class="p">];</span>
</code></pre></div></div>

<p>One subtlety only a real device exposed: when you pinch-zoom into an all-subject area and lasso, a fresh crop has no background for Vision to segment against and finds nothing. Intersecting with the <em>already-detected</em> foreground (computed at full frame, where background was visible) makes it reliable regardless of zoom. The drawn outline is rasterised with the same path-to-mask converter the eraser already used, which guaranteed the two coordinate systems lined up.</p>

<h3 id="testing-this-required-a-real-device">Testing this required a real device</h3>

<p>None of this is verifiable in the simulator — <code class="language-plaintext highlighter-rouge">VNGenerateForegroundInstanceMaskRequest</code> simply returns nothing there. The regression tests for the apple split and the lasso clip run the real Vision pipeline on a bundled fixture image and only pass on hardware, wired into the same scheme so they build from the command line.</p>

<hr />

<h2 id="whats-next">What’s Next</h2>

<p>The editing experience works well for portraits and clearly-defined objects. The main thing I want to push next is performance. The CoreImage pipeline re-renders on every selection change, and at full resolution on older devices that starts to feel sluggish with multiple exclusion masks active. A Metal-native compositing pass would collapse the mask union, exclusion subtract, and colour-swap into a single render pass.</p>

<p>The Android migration branch is also in progress, same concept, different stack. That’s a post for another day.</p>

<hr />

<h2 id="a-note-on-bringing-back-the-live-camera">A Note on Bringing Back the Live Camera</h2>

<p>The live camera might still come back. The Metal pipeline I built and then shelved is still in the repo, and the more I think about it, the more I think the answer isn’t really “use ML instead of hue”. It’s to use hue better, with the lessons the 2012 version taught me.</p>

<p>The original shader’s problem wasn’t that it relied on colour. It was that it relied on <strong>raw RGB hue</strong>, which is a fragile signal once you leave a controlled lighting setup. Most of its failure modes have well-known fixes:</p>

<p><strong>Work in HSV, not RGB.</strong> RGB tangles colour identity with brightness, so a red shirt in shadow and a red shirt in sunlight read as different colours. HSV (or HSL) splits “what colour is this” from “how bright is it,” so you can keep a narrow threshold on Hue while letting Saturation and Value vary. That’s closer to what you actually want for “this red thing, under any lighting.”</p>

<p><strong>Handle the hue wrap-around.</strong> Hue is a circle, not a line. Red sits at both 0° and 360°, so a naive <code class="language-plaintext highlighter-rouge">abs(hue - target) &lt; threshold</code> misses half the reds in any image. The 2012 shader’s <code class="language-plaintext highlighter-rouge">min(dHue, 2π - dHue)</code> trick was already doing this correctly, but on its own it isn’t enough.</p>

<p><strong>Gate on brightness and saturation.</strong> In deep shadow or blown-out highlights, hue stops meaning anything: a black pixel has no colour, a glare pixel has every colour. A minimum Value and Saturation cutoff keeps the splash from leaking into dark backgrounds and white walls, which were two of the original’s most obvious failure modes.</p>

<p><strong>Normalise for white balance.</strong> iPhone auto white balance shifts the whole hue distribution scene-to-scene, which is why a “red” threshold tuned indoors falls apart outdoors. A grey-world pass (or sampling a known reference card) before selection gives you a stable colour space to threshold against.</p>

<p>None of this needs ML. A live HSV selector with brightness gating and white-balance normalisation would already be a real step up from the 2012 version, and it runs comfortably at 60fps in a Metal compute shader. The ML editor handles “select the dog”. A smarter live camera could handle “splash everything red in this room”. The two would complement each other rather than compete.</p>

<p>So it isn’t abandoned. Just queued for a future post.</p>]]></content><author><name>Fernando Bass</name></author><category term="ios" /><category term="machine-learning" /><category term="ios" /><category term="objective-c" /><category term="metal" /><category term="vision" /><category term="avfoundation" /><category term="fastlane" /><category term="testflight" /><category term="core-image" /><summary type="html"><![CDATA[How I revived SplashCam, my first iOS app from 2012, into Splash Image: a photo editor that uses on-device machine learning to keep your chosen subject in colour while everything else turns grey.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Supercharging Plex Transcoding on Synology using an M.2 NVMe SSD</title><link href="https://ferbass.xyz/2026/06/07/supercharging-plex-transcoding.html" rel="alternate" type="text/html" title="Supercharging Plex Transcoding on Synology using an M.2 NVMe SSD" /><published>2026-06-07T16:04:09+09:00</published><updated>2026-06-07T16:04:09+09:00</updated><id>https://ferbass.xyz/2026/06/07/supercharging-plex-transcoding</id><content type="html" xml:base="https://ferbass.xyz/2026/06/07/supercharging-plex-transcoding.html"><![CDATA[<p>If you run a Plex Media Server on a Synology NAS, you probably know the struggle: direct playing a 4K movie is buttery smooth, but the second someone needs a subtitle burned in or drops the resolution, the mechanical hard drives start grinding.</p>

<p>The loud “crunching” sounds, the occasional buffering wheel, and the overall sluggishness of the NAS are all symptoms of a severe I/O bottleneck. Mechanical hard drives (HDDs) are great for sequentially reading large video files, but they are terrible at handling the thousands of rapid, random read/write operations required for live video transcoding.</p>

<p>I recently set out to fix this by offloading all that heavy lifting to an M.2 NVMe SSD. Here is how I bypassed Synology’s artificial software locks to build a dedicated Plex transcode buffer.</p>

<h2 id="the-problem-synologys-nvme-lock">The Problem: Synology’s NVMe Lock</h2>

<p>Many modern Synology units (like my DS920+) come with dual M.2 NVMe slots on the bottom. The catch? Synology’s operating system (DSM) officially restricts these slots to be used <em>only</em> as a read/write cache, not as an independent storage volume (unless you buy their wildly overpriced, proprietary Synology-branded SSDs).</p>

<p>Using standard NVMe drives as a standard cache doesn’t actually solve the Plex transcoding issue. We need a dedicated, addressable volume where we can tell Plex to dump its temporary files.</p>

<p>On top of that, I wasn’t using the M.2 slot as a read/write cache anyway. My workload doesn’t involve the kind of frequent, repeated random access that a cache actually accelerates, so dedicating the slot to caching would have left that NVMe drive sitting there doing nothing useful. Turning it into a dedicated transcode volume puts it to far better use.</p>

<h2 id="the-solution-bypassing-the-lock-via-terminal">The Solution: Bypassing the Lock via Terminal</h2>

<p>Thankfully, the homelab community is incredible. A developer named <a href="https://github.com/007revad">007revad</a> maintains a set of scripts that safely patches DSM’s database to allow any standard NVMe drive to be used as a standard storage pool.</p>

<p>Here is the step-by-step process I took to get it working.</p>

<h3 id="step-1-run-the-creation-script-via-ssh">Step 1: Run the Creation Script via SSH</h3>

<p>First, SSH into the NAS and switch to the root user:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo</span> <span class="nt">-i</span>
</code></pre></div></div>

<p>Next, run the dedicated M.2 volume creation script:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bash &lt;<span class="o">(</span>curl <span class="nt">-sL</span> https://raw.githubusercontent.com/007revad/Synology_M2_volume/main/syno_create_m2_volume.sh<span class="o">)</span>
</code></pre></div></div>

<p>The script is interactive. When prompted, I selected:</p>
<ol>
  <li><strong>Basic</strong> (since I only installed one M.2 drive).</li>
  <li><strong>Multi Volume</strong> (This is crucial: it allows you to slightly under-provision the drive later to extend the SSD’s lifespan under heavy write loads).</li>
</ol>

<p>The script automatically bypassed the UI locks and built the underlying storage pool.</p>

<h3 id="step-2-finalize-in-storage-manager">Step 2: Finalize in Storage Manager</h3>

<p>With the terminal work done, I jumped back into the Synology DSM web interface.</p>

<ol>
  <li>Opened <strong>Storage Manager</strong>.</li>
  <li>Selected the newly created Storage Pool and clicked <strong>Create &gt; Create Volume</strong>.</li>
  <li>Formatted it as <strong>Btrfs</strong>.</li>
  <li>Went to the Storage Pool settings and enabled <strong>SSD TRIM</strong> (highly recommended to keep the drive healthy).</li>
</ol>

<p>Just like that, I had a lightning-fast <code class="language-plaintext highlighter-rouge">Volume 2</code> ready to go.</p>

<h3 id="step-3-route-plex-to-the-ssd">Step 3: Route Plex to the SSD</h3>

<p>With the volume mounted, the final step was telling Plex to use it.</p>

<ol>
  <li>In <strong>File Station</strong>, I created a new shared folder on Volume 2 called <code class="language-plaintext highlighter-rouge">PlexTranscode</code>. (I made sure the internal system user <code class="language-plaintext highlighter-rouge">PlexMediaServer</code> had Read/Write permissions).</li>
  <li>Inside the <strong>Plex Web App</strong>, I navigated to <strong>Settings &gt; Transcoder</strong>.</li>
  <li>Under <strong>Transcoder temporary directory</strong>, I pointed it to the new path: <code class="language-plaintext highlighter-rouge">/volume2/PlexTranscode</code>.</li>
  <li>Saved the changes.</li>
</ol>

<h2 id="the-results-night-and-day">The Results: Night and Day</h2>

<p>While I didn’t run synthetic benchmarks, the real-world improvements were immediate and massive:</p>

<ul>
  <li><strong>Zero Drive Thrashing:</strong> Because the high-stress, random read/write chunks are now being dumped onto the NVMe drive, the HDDs just quietly spin and serve the main video file. The NAS is essentially silent during a transcode.</li>
  <li><strong>Instant Scrubbing:</strong> Skipping forward or rewinding a transcoded stream used to take a few seconds to catch up. Now, it happens almost instantaneously.</li>
  <li><strong>Responsive NAS:</strong> Previously, a heavy 4K transcode would make navigating the DSM web interface or browsing files feel sluggish. By removing the I/O bottleneck, the system feels snappy again regardless of what Plex is doing.</li>
  <li><strong>Less Wear and Tear:</strong> Mechanical drives aren’t built for non-stop random writes. Shifting that abuse to an SSD (which is literally designed for it) will likely extend the lifespan of my bulk storage drives.</li>
</ul>

<p>If you have an unused M.2 slot on your Synology and host Plex for your friends and family, this is hands-down one of the best upgrades you can do. It transforms the user experience from “good enough” to incredibly smooth.</p>]]></content><author><name>Fernando Bass</name></author><category term="Homelab" /><category term="Plex" /><category term="Synology" /><category term="nas" /><category term="plex" /><category term="nvme" /><category term="synology" /><category term="optimization" /><category term="homelab" /><summary type="html"><![CDATA[If you run a Plex Media Server on a Synology NAS, you probably know the struggle: direct playing a 4K movie is buttery smooth, but the second someone needs a...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running an Internal Blog to Document Your Homelab</title><link href="https://ferbass.xyz/2026/06/06/internal-blog-homelab-docs.html" rel="alternate" type="text/html" title="Running an Internal Blog to Document Your Homelab" /><published>2026-06-06T00:00:00+09:00</published><updated>2026-06-06T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/06/internal-blog-homelab-docs</id><content type="html" xml:base="https://ferbass.xyz/2026/06/06/internal-blog-homelab-docs.html"><![CDATA[<p>This is a follow-up to <a href="https://ferbass.xyz/2026/05/28/local-services-with-pihole-npm-letsencrypt.html">Clean HTTPS URLs for Your Home Server Services</a>, where I covered setting up Pi-hole, Nginx Proxy Manager, and a wildcard Let’s Encrypt certificate to get clean HTTPS URLs for all local services. If you have not read that one yet, it is a good starting point.</p>

<p>Keeping track of a homelab gets complicated fast. Which port does that service run on? Why did I configure Pi-hole that way? What was the fix for that weird networking issue six months ago? Writing it down somewhere persistent saves a lot of pain later. This post covers how I set up a private Jekyll blog, hosted on my home server, that auto-deploys from a GitHub repo whenever I push a new post.</p>

<h2 id="the-setup">The Setup</h2>

<p>The stack is intentionally simple:</p>

<ul>
  <li><strong>Jekyll</strong> for static site generation</li>
  <li><strong>GitHub</strong> to store the source and posts</li>
  <li><strong>GitHub Actions</strong> to build and deploy on every push</li>
  <li><strong>nginx</strong> running in Docker on CasaOS to serve the built site</li>
  <li><strong>Nginx Proxy Manager</strong> for HTTPS with a wildcard Let’s Encrypt cert</li>
  <li><strong>Tailscale</strong> to let GitHub Actions reach the home server securely</li>
</ul>

<p>The result is a site at <code class="language-plaintext highlighter-rouge">https://docs.yourdomain.com</code> reachable from any device on the local network, with a real trusted certificate.</p>

<h2 id="why-jekyll">Why Jekyll</h2>

<p>Jekyll builds a static site from Markdown files. No database, no PHP, no runtime to maintain. The output is just HTML and CSS served by nginx. It is fast, simple, and the source files are plain text that live comfortably in git.</p>

<p>For internal documentation, the content model maps well too. Posts for dated notes and writeups, and a custom <code class="language-plaintext highlighter-rouge">_services</code> collection for living documentation about each running service.</p>

<h2 id="repo-structure">Repo Structure</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>internal-blog/
├── _config.yml
├── _posts/
│   └── 2026-05-28-homelab-setup.md
├── _services/
│   ├── sonarr.md
│   ├── radarr.md
│   ├── pihole.md
│   └── ...
├── _layouts/
│   ├── default.html
│   ├── post.html
│   ├── page.html
│   └── service.html
├── assets/css/style.css
├── index.html
├── services.html
└── .github/workflows/deploy.yml
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">_services</code> collection is the most useful part. Each service gets its own Markdown file with frontmatter for the URL, port, and status:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">---</span>
<span class="na">title</span><span class="pi">:</span> <span class="s">Sonarr</span>
<span class="na">layout</span><span class="pi">:</span> <span class="s">service</span>
<span class="na">url_local</span><span class="pi">:</span> <span class="s">https://sonarr.yourdomain.com</span>
<span class="na">port</span><span class="pi">:</span> <span class="m">8989</span>
<span class="na">status</span><span class="pi">:</span> <span class="s">running</span>
<span class="na">description</span><span class="pi">:</span> <span class="s">TV series management and download automation</span>
<span class="nn">---</span>

TV show manager. Monitors RSS feeds and downloads episodes automatically.

<span class="gu">## Notes</span>
<span class="p">
-</span> Connected to Prowlarr for indexer management
<span class="p">-</span> Downloads go to /mnt/Storage1/Media/TV
</code></pre></div></div>

<p>For Jekyll to treat <code class="language-plaintext highlighter-rouge">_services</code> as a collection, declare it in <code class="language-plaintext highlighter-rouge">_config.yml</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">collections</span><span class="pi">:</span>
  <span class="na">services</span><span class="pi">:</span>
    <span class="na">output</span><span class="pi">:</span> <span class="kc">true</span>
</code></pre></div></div>

<p>Without this, the files in <code class="language-plaintext highlighter-rouge">_services</code> are ignored. Once declared, the homepage and services page loop over the collection to render a table of all services with their URLs and status. It becomes a quick reference for anything running on the server.</p>

<h2 id="the-deployment-problem">The Deployment Problem</h2>

<p>GitHub Actions runners are on the public internet. The home server is on a private LAN. They cannot reach each other directly.</p>

<p>The fix is Tailscale. The home server already runs Tailscale, and the GitHub Actions workflow connects the runner to the same Tailscale network using an auth key before running rsync. From that point the runner can reach the server at its Tailscale IP as if it were on the same local network.</p>

<h2 id="the-github-actions-workflow">The GitHub Actions Workflow</h2>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Build and Deploy</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build-and-deploy</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>

    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v6</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Ruby</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s2">"</span><span class="s">4.0"</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="kc">true</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build Jekyll site</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec jekyll build</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">JEKYLL_ENV</span><span class="pi">:</span> <span class="s">production</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Connect to Tailscale</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">tailscale/github-action@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">authkey</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">version</span><span class="pi">:</span> <span class="s">latest</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy via rsync</span>
        <span class="na">env</span><span class="pi">:</span>
          <span class="na">SSH_KEY_B64</span><span class="pi">:</span> <span class="s">$</span>
          <span class="na">DEPLOY_HOST</span><span class="pi">:</span> <span class="s">$</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">mkdir -p ~/.ssh</span>
          <span class="s">echo "$SSH_KEY_B64" | base64 -d &gt; ~/.ssh/deploy_key</span>
          <span class="s">chmod 600 ~/.ssh/deploy_key</span>
          <span class="s">rsync -avzr --delete \</span>
            <span class="s">-e "ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no" \</span>
            <span class="s">_site/ \</span>
            <span class="s">user@${DEPLOY_HOST}:/home/user/docs-site/</span>
</code></pre></div></div>

<p>A few things worth calling out:</p>

<p><strong>The SSH key is stored base64-encoded.</strong> GitHub Actions can mangle multiline secrets when they are retrieved in a shell script. Encoding the private key as a single base64 line and decoding it in the workflow avoids the issue entirely. This tripped me up for a while before I found it.</p>

<p><strong>Tailscale connects before rsync runs.</strong> The runner joins your Tailscale network as an ephemeral node, runs the deploy, then disappears. No persistent node, no cleanup needed.</p>

<p><strong>rsync with <code class="language-plaintext highlighter-rouge">--delete</code></strong> keeps the served directory in sync with exactly what Jekyll built. Files removed from the repo are removed from the server on the next deploy.</p>

<h2 id="secrets-required">Secrets Required</h2>

<table>
  <thead>
    <tr>
      <th>Secret</th>
      <th>Value</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">TAILSCALE_AUTHKEY</code></td>
      <td>Ephemeral reusable auth key from Tailscale admin</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DEPLOY_HOST</code></td>
      <td>Tailscale IP of the home server</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DEPLOY_SSH_KEY</code></td>
      <td>Base64-encoded Ed25519 private key</td>
    </tr>
  </tbody>
</table>

<p>Generate the deploy key on the server and add the public key to <code class="language-plaintext highlighter-rouge">authorized_keys</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh-keygen <span class="nt">-t</span> ed25519 <span class="nt">-C</span> <span class="s2">"github-actions-deploy"</span> <span class="nt">-f</span> ~/.ssh/github_deploy_key <span class="nt">-N</span> <span class="s2">""</span>
<span class="nb">cat</span> ~/.ssh/github_deploy_key.pub <span class="o">&gt;&gt;</span> ~/.ssh/authorized_keys
</code></pre></div></div>

<p>Then encode the private key for the GitHub secret:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">base64</span> <span class="nt">-w</span> 0 ~/.ssh/github_deploy_key
</code></pre></div></div>

<p>Paste that single line as the <code class="language-plaintext highlighter-rouge">DEPLOY_SSH_KEY</code> secret value.</p>

<h2 id="serving-with-nginx">Serving with nginx</h2>

<p>The built site lands at <code class="language-plaintext highlighter-rouge">/home/user/docs-site/</code> on the server. A lightweight nginx container serves it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--name</span> docs <span class="se">\</span>
  <span class="nt">--restart</span> unless-stopped <span class="se">\</span>
  <span class="nt">-p</span> 8899:80 <span class="se">\</span>
  <span class="nt">-v</span> /home/user/docs-site:/usr/share/nginx/html:ro <span class="se">\</span>
  nginx:alpine
</code></pre></div></div>

<p>Nginx Proxy Manager handles the HTTPS layer, routing <code class="language-plaintext highlighter-rouge">docs.yourdomain.com</code> to port <code class="language-plaintext highlighter-rouge">8899</code> with the wildcard <code class="language-plaintext highlighter-rouge">*.yourdomain.com</code> certificate.</p>

<p>One gotcha: bind the container to <code class="language-plaintext highlighter-rouge">0.0.0.0:8899</code> not <code class="language-plaintext highlighter-rouge">127.0.0.1:8899</code>. NPM runs in its own Docker container and cannot reach the host loopback. Use the server LAN IP as the forward host in NPM instead of <code class="language-plaintext highlighter-rouge">127.0.0.1</code>.</p>

<h2 id="services-table">Services Table</h2>

<p>All services are documented in the <code class="language-plaintext highlighter-rouge">_services</code> collection and rendered as a table:</p>

<table>
  <thead>
    <tr>
      <th>Service</th>
      <th>URL</th>
      <th>Port</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Sonarr</td>
      <td>https://sonarr.yourdomain.com</td>
      <td>8989</td>
    </tr>
    <tr>
      <td>Radarr</td>
      <td>https://radarr.yourdomain.com</td>
      <td>7878</td>
    </tr>
    <tr>
      <td>Bazarr</td>
      <td>https://bazarr.yourdomain.com</td>
      <td>6767</td>
    </tr>
    <tr>
      <td>Prowlarr</td>
      <td>https://prowlarr.yourdomain.com</td>
      <td>9696</td>
    </tr>
    <tr>
      <td>Pi-hole</td>
      <td>https://pihole.yourdomain.com</td>
      <td>8800</td>
    </tr>
    <tr>
      <td>Portainer</td>
      <td>https://portainer.yourdomain.com</td>
      <td>9000</td>
    </tr>
    <tr>
      <td>qBittorrent</td>
      <td>https://qt.yourdomain.com</td>
      <td>8181</td>
    </tr>
    <tr>
      <td>Nginx Proxy Manager</td>
      <td>https://npm.yourdomain.com</td>
      <td>81</td>
    </tr>
    <tr>
      <td>Synology NAS</td>
      <td>https://nas.yourdomain.com</td>
      <td>5000</td>
    </tr>
    <tr>
      <td>Router</td>
      <td>https://router.yourdomain.com</td>
      <td>80</td>
    </tr>
    <tr>
      <td>Internal Docs</td>
      <td>https://docs.yourdomain.com</td>
      <td>8899</td>
    </tr>
  </tbody>
</table>

<h2 id="writing-workflow">Writing Workflow</h2>

<p>Once everything is set up, adding a post is just:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>vim _posts/2026-05-31-some-topic.md
git add <span class="nb">.</span> <span class="o">&amp;&amp;</span> git commit <span class="nt">-m</span> <span class="s2">"add post"</span> <span class="o">&amp;&amp;</span> git push
</code></pre></div></div>

<p>The site updates in about 30 seconds. No SSH, no server management, no manual steps.</p>

<p>For quick notes, the GitHub web editor works too. Edit a file directly on github.com and the deploy triggers automatically.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="homelab" /><category term="networking" /><category term="jekyll" /><category term="github-actions" /><category term="nginx" /><category term="tailscale" /><category term="self-hosted" /><category term="documentation" /><summary type="html"><![CDATA[This is a follow-up to Clean HTTPS URLs for Your Home Server Services, where I covered setting up Pi-hole, Nginx Proxy Manager, and a wildcard Let's Encrypt...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My Fish Shell Setup</title><link href="https://ferbass.xyz/2026/06/05/my-fish-setup.html" rel="alternate" type="text/html" title="My Fish Shell Setup" /><published>2026-06-05T00:00:00+09:00</published><updated>2026-06-05T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/05/my-fish-setup</id><content type="html" xml:base="https://ferbass.xyz/2026/06/05/my-fish-setup.html"><![CDATA[<p>Notes on how I have fish shell set up on macOS. Fish is the shell I land
in every time I open Alacritty, so I spend a lot of time here.</p>

<h2 id="why-fish">Why Fish</h2>

<p>Bash and zsh are fine, but fish gives me good defaults out of the box.
Syntax highlighting as I type, autosuggestions from history, and a sane
scripting language. It is not POSIX, which means I cannot paste random
shell snippets and expect them to work, but for daily interactive use I
prefer it.</p>

<p>Fish lives at <code class="language-plaintext highlighter-rouge">/opt/homebrew/bin/fish</code> (installed via Homebrew) and is set
as the shell in my Alacritty config.</p>

<h2 id="auto-attach-to-tmux">Auto attach to tmux</h2>

<p>The first thing my config does after loading Homebrew is jump straight
into tmux:</p>

<pre><code class="language-fish">if status is-interactive
and not set -q TMUX
    exec tmux new -As0
end
</code></pre>

<p><code class="language-plaintext highlighter-rouge">tmux new -As0</code> either attaches to session <code class="language-plaintext highlighter-rouge">0</code> if it exists, or creates
it. Combined with <code class="language-plaintext highlighter-rouge">exec</code>, the fish process is replaced by tmux, so I do
not have a stray shell hanging around outside the session.</p>

<p>The check on <code class="language-plaintext highlighter-rouge">TMUX</code> is important. Without it, every pane fish opens
inside tmux would try to start tmux again.</p>

<h2 id="greeting-and-basics">Greeting and basics</h2>

<pre><code class="language-fish">set fish_greeting
</code></pre>

<p>Empty greeting. I do not need fish to tell me hello every time.</p>

<pre><code class="language-fish">set -gx GPG_TTY (tty)
set -U EDITOR nvim
</code></pre>

<p><code class="language-plaintext highlighter-rouge">GPG_TTY</code> is required for GPG signing to prompt for the passphrase. I
sign commits, so this matters. <code class="language-plaintext highlighter-rouge">EDITOR</code> is universal so git, fzf and
anything else that calls into <code class="language-plaintext highlighter-rouge">$EDITOR</code> picks neovim.</p>

<h2 id="version-managers">Version managers</h2>

<p>I have a few language version managers installed. They are gated on
<code class="language-plaintext highlighter-rouge">status --is-interactive</code> so they do not slow down non interactive
sessions:</p>

<pre><code class="language-fish"># rbenv
if status --is-interactive
  set -x PATH $PATH "$(brew --prefix)/shared/rbenv"
  rbenv init - | source
end

# pyenv
if status --is-interactive
  set -Ux PYENV_ROOT $HOME/.pyenv
  set -U fish_user_paths $PYENV_ROOT/bin $fish_user_paths
  pyenv init - fish | source
  pyenv virtualenv-init - | source
end
</code></pre>

<p>I also have tfenv on PATH (for Terraform) and sdkman for the JVM
languages, set up through a conf.d snippet that ships with the sdkman
fish plugin.</p>

<h2 id="locale">Locale</h2>

<p>I set every <code class="language-plaintext highlighter-rouge">LC_*</code> variable to <code class="language-plaintext highlighter-rouge">en_US.UTF-8</code>. Worth being explicit
because some tools (ruby, postgres, certain ssh setups) will silently
fall back to C locale otherwise, which breaks UTF-8 handling:</p>

<pre><code class="language-fish">set -gx LANG "en_US.UTF-8"
set -gx LC_ALL "en_US.UTF-8"
# ...and the rest
</code></pre>

<h2 id="path-and-sdk-paths">PATH and SDK paths</h2>

<p>Android SDK, JDK 17 include path for native builds, libomp for compiling
some Python wheels, and a few extras:</p>

<pre><code class="language-fish">set -gx ANDROID_HOME /Users/ferbass/Library/Android/sdk
set -gx PATH $ANDROID_HOME/tools $ANDROID_HOME/platform-tools $PATH
set -gx CPPFLAGS "-I/opt/homebrew/opt/openjdk@17/include"
set -gx LDFLAGS "-L/opt/homebrew/opt/libomp/lib"
</code></pre>

<p>Plus <code class="language-plaintext highlighter-rouge">~/.local/bin</code> and <code class="language-plaintext highlighter-rouge">~/.tfenv/bin</code> near the front of PATH.</p>

<h2 id="disable-homebrew-analytics">Disable Homebrew analytics</h2>

<pre><code class="language-fish">set -gx HOMEBREW_NO_ANALYTICS 1
</code></pre>

<p>Small thing, but I do not need Homebrew sending usage data.</p>

<h2 id="aliases">Aliases</h2>

<p>I keep aliases short and few:</p>

<pre><code class="language-fish">alias assume="set -x GRANTE_SAML true; source /opt/homebrew/bin/assume.fish"
alias tns="tmux new -d -s"
</code></pre>

<ul>
  <li><code class="language-plaintext highlighter-rouge">assume</code> is for the <a href="https://www.granted.dev/">granted</a> AWS credential
tool. The env var puts it in SAML mode.</li>
  <li><code class="language-plaintext highlighter-rouge">tns foo</code> creates a detached tmux session named <code class="language-plaintext highlighter-rouge">foo</code>. Handy when I
want to spin up a background workspace without leaving the current one.</li>
</ul>

<h2 id="functions">Functions</h2>

<p>These live in <code class="language-plaintext highlighter-rouge">fish/functions/</code>. Each function gets its own file, which
fish auto loads on first call.</p>

<pre><code class="language-fish">function cat
    bat $argv
end
</code></pre>

<p><code class="language-plaintext highlighter-rouge">cat</code> is shadowed by <a href="https://github.com/sharkdp/bat">bat</a> for the
syntax highlighting and line numbers.</p>

<pre><code class="language-fish">function ls --wraps='eza --icons --group-directories-first'
    eza $argv --icons --group-directories-first
end
</code></pre>

<p><code class="language-plaintext highlighter-rouge">ls</code> is shadowed by <a href="https://github.com/eza-community/eza">eza</a> for
icons and directories first.</p>

<pre><code class="language-fish">function vim
    if type -q nvim
        command nvim $argv
    else
        echo "nvim is not installed"
    end
end
</code></pre>

<p><code class="language-plaintext highlighter-rouge">vim</code> runs <code class="language-plaintext highlighter-rouge">nvim</code> if it is installed. Keeps muscle memory working.</p>

<p>A couple of small utilities:</p>

<pre><code class="language-fish">function myip
    curl ifconfig.me
end

function lockscreen --description '[macOS] Lock your screen without Log out the user'
    pmset displaysleepnow
end
</code></pre>

<p>I also have a few git helpers like <code class="language-plaintext highlighter-rouge">default_branch &lt;url&gt;</code> (uses
<code class="language-plaintext highlighter-rouge">ls-remote --symref</code> to pull the default branch name) and
<code class="language-plaintext highlighter-rouge">ensure_remote &lt;name&gt; &lt;url&gt;</code> (adds or updates a remote).</p>

<h2 id="tmux--ai-status-icon">Tmux + AI status icon</h2>

<p>When I run <code class="language-plaintext highlighter-rouge">claude</code> or <code class="language-plaintext highlighter-rouge">gemini</code> inside tmux, I want a robot icon to
appear in the tmux window name so I can see at a glance which window
has an AI session running. Fish has <code class="language-plaintext highlighter-rouge">fish_preexec</code> and <code class="language-plaintext highlighter-rouge">fish_postexec</code>
events that fire before and after every command:</p>

<pre><code class="language-fish">function __tmux_ai_preexec --on-event fish_preexec
    if set -q TMUX
        set cmd (string split ' ' $argv[1])[1]
        switch $cmd
            case claude claude-personal
                tmux set-option -p @ai_running "🤖"
            case gemini
                tmux set-option -p @ai_running "🤖"
        end
    end
end

function __tmux_ai_postexec --on-event fish_postexec
    if set -q TMUX
        tmux set-option -pu @ai_running
    end
end
</code></pre>

<p>The tmux side picks this up through a window name format that reads
<code class="language-plaintext highlighter-rouge">@ai_running</code>. The result is a small robot icon next to the window
title while the command is running.</p>

<h2 id="keybindings">Keybindings</h2>

<p>In <code class="language-plaintext highlighter-rouge">conf.d/keybindings.fish</code>:</p>

<pre><code class="language-fish">bind \e. history-token-search-backward
</code></pre>

<p>This gives me <code class="language-plaintext highlighter-rouge">Alt+.</code> to paste the last token from the previous command,
which is the readline behaviour I am used to from bash and zsh. Fish
does not ship it by default, so I add it back.</p>

<h2 id="prompt">Prompt</h2>

<p>I use a small custom prompt in <code class="language-plaintext highlighter-rouge">functions/fish_prompt.fish</code>. It shows
the current directory (shortened), the git branch, and the exit code
in red if the last command failed. Two lines, so the command always
starts at column 1.</p>

<h2 id="local-secrets">Local secrets</h2>

<p>Last line of the config sources a gitignored file for anything machine
specific:</p>

<pre><code class="language-fish">if test -f ~/.config/fish/config.local.fish
    source ~/.config/fish/config.local.fish
end
</code></pre>

<p>That is where any API tokens or credentials live, so they never end up
in the dotfiles repo.</p>

<h2 id="plugins">Plugins</h2>

<p>Managed by <a href="https://github.com/jorgebucaran/fisher">fisher</a>. The
<code class="language-plaintext highlighter-rouge">fish_plugins</code> file lists what is installed:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jorgebucaran/fisher
reitzig/sdkman-for-fish@v2.0.0
</code></pre></div></div>

<p>I keep the plugin list short on purpose. Most of what other people use
plugins for, fish already does natively or I have a small function for.</p>

<h2 id="that-is-it">That is it</h2>

<p>If you want to copy any of this, it all lives in my
<a href="https://github.com/ferbass/dotfiles">dotfiles repo</a> under <code class="language-plaintext highlighter-rouge">fish/</code>.</p>

<p><em>This post was put together with help from an AI assistant.</em></p>]]></content><author><name>Fernando Bass</name></author><category term="setup" /><category term="shell" /><category term="fish" /><category term="macos" /><category term="tmux" /><summary type="html"><![CDATA[Notes on how I have fish shell set up on macOS. Fish is the shell I land in every time I open Alacritty, so I spend a lot of time]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">My Alacritty Setup</title><link href="https://ferbass.xyz/2026/06/03/my-alacritty-setup.html" rel="alternate" type="text/html" title="My Alacritty Setup" /><published>2026-06-03T00:00:00+09:00</published><updated>2026-06-03T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/03/my-alacritty-setup</id><content type="html" xml:base="https://ferbass.xyz/2026/06/03/my-alacritty-setup.html"><![CDATA[<p>Quick rundown of how I have Alacritty configured on macOS. The whole file
is around 30 lines of TOML and lives at <code class="language-plaintext highlighter-rouge">~/.config/alacritty/alacritty.toml</code>.</p>

<h2 id="why-alacritty">Why Alacritty</h2>

<p>I want a terminal that opens fast, scrolls fast, and gets out of my way.
Alacritty does that. It is GPU accelerated, has no built in tabs or splits
(I let tmux handle those), and the config is plain TOML so it is easy to
read and to put in a dotfiles repo.</p>

<h2 id="theme">Theme</h2>

<p>I use the Argonaut theme. The themes repo is cloned as a submodule under
<code class="language-plaintext highlighter-rouge">~/.config/alacritty/themes</code> and pulled in with a single import line:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">general</span><span class="k">]</span>
<span class="n">import</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span><span class="s">"~/.config/alacritty/themes/themes/argonaut.toml"</span><span class="p">]</span>
</code></pre></div></div>

<p>Using <code class="language-plaintext highlighter-rouge">~</code> rather than the full path means the file is portable across
machines.</p>

<h2 id="font">Font</h2>

<p>Hack Nerd Font at size 16. Hack has clean glyphs at small sizes and the
Nerd Font version includes icons that work well in tools like fish, lazygit,
or my neovim file tree.</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">font</span><span class="k">]</span>
<span class="n">size</span> <span class="o">=</span><span class="w"> </span><span class="mi">16</span>

<span class="k">[</span><span class="n">font</span><span class="k">.</span><span class="n">normal</span><span class="k">]</span>
<span class="n">family</span> <span class="o">=</span><span class="w"> </span><span class="s">"Hack Nerd Font"</span>
<span class="n">style</span> <span class="o">=</span><span class="w"> </span><span class="s">"Regular"</span>

<span class="k">[</span><span class="n">font</span><span class="k">.</span><span class="n">bold</span><span class="k">]</span>
<span class="n">family</span> <span class="o">=</span><span class="w"> </span><span class="s">"Hack Nerd Font"</span>
<span class="n">style</span> <span class="o">=</span><span class="w"> </span><span class="s">"Bold"</span>

<span class="k">[</span><span class="n">font</span><span class="k">.</span><span class="n">italic</span><span class="k">]</span>
<span class="n">family</span> <span class="o">=</span><span class="w"> </span><span class="s">"Hack Nerd Font"</span>
<span class="n">style</span> <span class="o">=</span><span class="w"> </span><span class="s">"Italic"</span>

<span class="k">[</span><span class="n">font</span><span class="k">.</span><span class="n">bold_italic</span><span class="k">]</span>
<span class="n">family</span> <span class="o">=</span><span class="w"> </span><span class="s">"Hack Nerd Font"</span>
<span class="n">style</span> <span class="o">=</span><span class="w"> </span><span class="s">"Bold Italic"</span>
</code></pre></div></div>

<p>I had <code class="language-plaintext highlighter-rouge">Bold</code> style set to <code class="language-plaintext highlighter-rouge">Regular</code> for a long time without noticing.
If you ever wonder why your bold text does not look bold, that is the
first place to check.</p>

<h2 id="window">Window</h2>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">window</span><span class="k">]</span>
<span class="n">option_as_alt</span> <span class="o">=</span><span class="w"> </span><span class="s">"OnlyLeft"</span>
<span class="n">decorations</span> <span class="o">=</span><span class="w"> </span><span class="s">"Buttonless"</span>
<span class="n">padding</span> <span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">x</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">8</span><span class="p">,</span><span class="w"> </span><span class="n">y</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="mi">8</span><span class="w"> </span><span class="p">}</span>
</code></pre></div></div>

<p>A few small things that make a big difference:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">option_as_alt = "OnlyLeft"</code> makes the left Option key behave like Alt.
This is what lets things like <code class="language-plaintext highlighter-rouge">Alt+.</code> in fish or <code class="language-plaintext highlighter-rouge">Alt+1..9</code> in tmux
actually fire. The right Option key keeps the macOS behavior so I can
still type accented characters.</li>
  <li><code class="language-plaintext highlighter-rouge">decorations = "Buttonless"</code> keeps the traffic light buttons but drops
the rest of the title bar. Cleaner look without losing the close button.</li>
  <li>A bit of padding so text does not touch the window edge.</li>
</ul>

<h2 id="scrollback">Scrollback</h2>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">scrolling</span><span class="k">]</span>
<span class="n">history</span> <span class="o">=</span><span class="w"> </span><span class="mi">10000</span>
</code></pre></div></div>

<p>10k lines. Enough to scroll up after a long build without losing things.</p>

<h2 id="selection-and-mouse">Selection and Mouse</h2>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">selection</span><span class="k">]</span>
<span class="n">save_to_clipboard</span> <span class="o">=</span><span class="w"> </span><span class="kc">true</span>

<span class="k">[</span><span class="n">mouse</span><span class="k">]</span>
<span class="n">hide_when_typing</span> <span class="o">=</span><span class="w"> </span><span class="kc">true</span>
</code></pre></div></div>

<p>Selecting text automatically copies it to the system clipboard, so I do
not need cmd+c every time. And the mouse cursor disappears while I type,
which is small but nice.</p>

<h2 id="cursor">Cursor</h2>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">cursor</span><span class="k">]</span>
<span class="n">style</span> <span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="n">shape</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"Beam"</span><span class="p">,</span><span class="w"> </span><span class="n">blinking</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"On"</span><span class="w"> </span><span class="p">}</span>
</code></pre></div></div>

<p>Beam cursor that blinks. Easier to spot than a block when I am scanning
the screen.</p>

<h2 id="shell">Shell</h2>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">terminal</span><span class="k">.</span><span class="n">shell</span><span class="k">]</span>
<span class="n">program</span> <span class="o">=</span><span class="w"> </span><span class="s">"/opt/homebrew/bin/fish"</span>
</code></pre></div></div>

<p>Fish, installed through Homebrew. Post about my fish setup is coming
separately.</p>

<h2 id="that-is-it">That is it</h2>

<p>The whole config fits on one screen, which I like. If you want to copy
parts of it, the file is in my <a href="https://github.com/ferbass/dotfiles">dotfiles repo</a>
under <code class="language-plaintext highlighter-rouge">alacritty/alacritty.toml</code>.</p>

<p><em>This post was put together with help from an AI assistant.</em></p>]]></content><author><name>Fernando Bass</name></author><category term="setup" /><category term="terminal" /><category term="alacritty" /><category term="macos" /><category term="terminal" /><summary type="html"><![CDATA[Quick rundown of how I have Alacritty configured on macOS. The whole file is around 30 lines of TOML and lives at]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Automating My Busywork With Scheduled AI Agents</title><link href="https://ferbass.xyz/2026/06/01/automating-my-busywork-with-ai-agents.html" rel="alternate" type="text/html" title="Automating My Busywork With Scheduled AI Agents" /><published>2026-06-01T00:00:00+09:00</published><updated>2026-06-01T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/06/01/automating-my-busywork-with-ai-agents</id><content type="html" xml:base="https://ferbass.xyz/2026/06/01/automating-my-busywork-with-ai-agents.html"><![CDATA[<p>For a while my week had three leaks I could never quite plug. Too many
Slack channels to keep up with, a review queue that grew faster than I
could drain it, and zero time to write down what actually shipped. None
of these are hard problems. They are just constant, and they eat the
hours where I would rather be building.</p>

<p>So I spent a weekend wiring up a set of scheduled AI agents to handle the
boring parts. Not chatbots I poke at, but jobs that run on a cron in the
cloud, do a specific task, and get out of the way. Here is what I built
and, more usefully, what I learned doing it.</p>

<h2 id="ai-is-not-just-for-writing-code">AI is not just for writing code</h2>

<p>Almost all of the AI conversation right now is about coding. Autocomplete,
agents that open pull requests, tools that refactor a file while you
watch. That is great, and I lean on it. But it quietly ignores the other
half of the job: the reading, the summarizing, and the keeping everyone in
the loop. That part never ships a feature, so it gets no hype, and it is
exactly where a model that is good at distilling text earns its keep.
Summarization is not the consolation prize. For me it has been the highest
leverage use of AI this year, and none of the three jobs below writes a
single line of production code.</p>

<h2 id="the-three-jobs">The three jobs</h2>

<p><strong>Slack digests.</strong> A few times a day an agent reads the channels I care
about, keeps only what happened in that window, and posts one tidy
summary to a private channel of mine. Each bullet that came from a thread
I was part of gets a small link back to the thread, so I can jump in if I
need to. I stopped scrolling and started skimming.</p>

<p><strong>A review drafter.</strong> Every hour during the workday, an agent looks for
pull requests waiting on my review, reads the diff, and leaves a pending
(draft) review with inline comments. It never submits anything. I open
the PR, the comments are already there waiting, I edit what I disagree
with and hit submit. Reviews that used to sit for a day now start in
minutes.</p>

<p><strong>A weekly write-up.</strong> Every Monday an agent looks back over the last
seven days across our repositories, figures out the major changes,
possible issues, and things worth watching, and publishes a post to a
small internal site. It opens with genuine credit to the people who
shipped, includes a little contributor leaderboard, and ties code back to
the tickets it closed. The team gets a readable record of the week
without anyone writing it by hand.</p>

<h2 id="how-it-is-glued-together">How it is glued together</h2>

<p>All of this rides on Claude Code’s scheduled remote agents, which it calls
<a href="https://code.claude.com/docs/en/routines">routines</a>. A routine is just a
saved Claude Code setup, a prompt plus the repositories and connectors it
needs, packaged once and run automatically. The trigger can be a schedule,
an on-demand API call, or a GitHub event, and it all runs on Anthropic’s
cloud rather than my laptop. I set each one up through a short scheduling
flow: give it a name, a cron expression, a prompt, and the model to use.
The cron is in UTC with a one hour minimum, so I do the timezone math from
where I live, and for the weekday jobs I add a small guard at the top of
the prompt that checks the local day and bails out on weekends.</p>

<p>Routines are the path of least resistance for me because I already live in
Claude Code, but there is nothing magic about them. The same idea works
with a plain cron job on a server you own, or a scheduled GitHub Actions
workflow that calls a model on a timer. What you are actually building is
an agent with a good prompt and something that fires it on schedule. The
routine just spares me from babysitting a box or a YAML file, and it runs
in the cloud so nothing breaks when my machine is asleep.</p>

<p>When a routine fires, it spins up a fresh, isolated session in Anthropic’s
cloud. The session gets its own git checkout of whatever repositories I
point it at, plus the usual tools: a shell, file read and search, git, and
web fetch. It starts with zero memory of previous runs, which is exactly
why the prompt has to be completely self-contained. The prompt is the real
product here. Everything the agent knows about the task lives inside it,
so I spend most of my time wording the steps precisely: what to gather,
what to skip, what “done” looks like, and what never to do.</p>

<p>External services come in through MCP connectors. Slack, GitHub, and the
issue tracker each connect once, and then any routine can use them. GitHub
comes through an MCP server authenticated as me, so the agents can search
pull requests, read diffs, and leave draft reviews. The weekly write-up
goes further and uses plain git in the session to clone a repo, commit a
new post, and push it, with a rebase first so it never trips over commits
that landed in the meantime. No tokens pasted into prompts, no secrets in
the repo.</p>

<p>The decision that mattered most was picking the model per routine. Pure
summarizing runs on the cheapest fast model. Code review runs on the
strongest one, because that is where quality actually shows up. The weekly
analysis sits in the middle, on a mid model with a medium effort setting I
spell out in the prompt. Matching the model to the job kept the cost sane
without making the output worse where it counts.</p>

<h2 id="what-a-routine-actually-looks-like">What a routine actually looks like</h2>

<p>There is not much to one. It is a schedule plus a prompt. Here is a
trimmed, genericized version of my review drafter, the routine that taught
me the most:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code># schedule: 0 0-9,23 * * *   (hourly, ~8am to 6pm my time, weekdays)
# model:    the strongest one, since this is code review

You review pull requests for my GitHub account. You run hourly on
weekdays. Use the GitHub tools (you are authenticated as me). There is
no gh CLI; use the GitHub MCP tools.

STEP 0  Run `TZ=&lt;my zone&gt; date +%u`. If it is Saturday or Sunday, stop
        and do nothing.

STEP 1  Find open PRs that request my review across my org. If there
        are none, stop.

STEP 2  For each PR, list its existing reviews.
        - If I already left a real review, skip the PR.
        - If a previous run left an EMPTY draft, delete it and redo.
        Also read my recent Slack posts and skip any PR already
        announced, so the hourly run never posts the same one twice.

STEP 3  Read the diff. Draft concrete inline comments (path, line,
        severity label, body). Create a PENDING review and attach the
        comments. Do NOT submit it.
        VERIFY: re-read the pending review and confirm it really has
        comments. If it came out empty, delete it and mark the PR as
        failed. Never report a draft that is not actually there.

STEP 4  Post one short Slack summary, listing only PRs that got a real,
        non-empty draft. Link each author to their GitHub profile.
        No em dashes. Write like a person.
</code></pre></div></div>

<p>The cron is the easy half. The prompt is where all the behavior lives,
including every mistake I had to learn the hard way. That empty-draft
check in step 3 exists because of a solid week of empty drafts.</p>

<h2 id="what-i-actually-learned">What I actually learned</h2>

<p>The plumbing was the easy part. The lessons were in the failure modes.</p>

<p><strong>Make the agent verify its own work.</strong> My proudest moment was also my
most embarrassing one. The review drafter happily reported “drafts ready”
for a week while leaving completely empty reviews behind. It had created
the review shell and never attached the comments. Now every agent that
claims it did something has to re-read the result and prove it, and it is
only allowed to report success when the thing is actually there.</p>

<p><strong>Ground everything in real data.</strong> An early version pulled ticket IDs
out of PR titles with a regex and cheerfully linked to issues that did
not exist, because some titles carried leftover placeholder text. The fix
was to stop trusting text that looks like an ID and only reference things
the issue tracker confirms are real. An accurate post with fewer links
beats a confident one full of dead ones.</p>

<p><strong>Write like a person.</strong> Left alone, these tools reach for em dashes, “in
summary”, and the same three transition words. I bake the opposite into
every prompt. I also scale the praise in the weekly post to how much
actually shipped, so a quiet week reads honest instead of inflated.</p>

<p><strong>Respect idempotency and timezones.</strong> An hourly job that does not
remember what it already did will spam you. A daily one that ignores the
clock will skip a post because a static site generator quietly refuses to
publish anything dated in the future. Both cost me a confused hour.</p>

<h2 id="was-it-worth-it">Was it worth it</h2>

<p>Yes, and not because it is clever. It is worth it because the busywork is
gone and the judgment stayed with me. I still decide what a review means
and whether the week went well. The agents just remove the friction
between me and that decision. That is the whole trick: automate the
gathering and the drafting, keep the thinking.</p>

<p>If you try this, start with the most repetitive, lowest-stakes task you
have, make it verify itself, and only then let it touch anything that
other people will see.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="automation" /><category term="ai" /><category term="ai" /><category term="agents" /><category term="automation" /><category term="workflow" /><category term="jekyll" /><summary type="html"><![CDATA[For a while my week had three leaks I could never quite plug. Too many Slack channels to keep up with, a review queue that grew faster than I could drain it,...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Neovim Cheatsheet</title><link href="https://ferbass.xyz/2026/05/29/neovim-cheatsheet.html" rel="alternate" type="text/html" title="Neovim Cheatsheet" /><published>2026-05-29T00:00:00+09:00</published><updated>2026-05-29T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/05/29/neovim-cheatsheet</id><content type="html" xml:base="https://ferbass.xyz/2026/05/29/neovim-cheatsheet.html"><![CDATA[<p>Personal cheatsheet for my Neovim setup. Leader key is <code class="language-plaintext highlighter-rouge">&lt;Space&gt;</code>.</p>

<h2 id="general">General</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Space&gt;sv</code></td>
      <td>Source <code class="language-plaintext highlighter-rouge">init.lua</code> (reload config)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">\</code></td>
      <td>Clear search highlight</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;w</code></td>
      <td><code class="language-plaintext highlighter-rouge">:w</code> (save buffer)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;j</code></td>
      <td><code class="language-plaintext highlighter-rouge">:j</code> (join lines, keep whitespace)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;J</code></td>
      <td><code class="language-plaintext highlighter-rouge">:j!</code> (join lines without space)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;cp</code></td>
      <td>Copy current file path to clipboard</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">qq</code></td>
      <td><code class="language-plaintext highlighter-rouge">:q</code> (quit window)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">QQ</code></td>
      <td><code class="language-plaintext highlighter-rouge">:q!</code> (force quit)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">qo</code></td>
      <td><code class="language-plaintext highlighter-rouge">:only</code> (close all other windows)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">q</code> / <code class="language-plaintext highlighter-rouge">Q</code></td>
      <td>Disabled (no accidental macro recording)</td>
    </tr>
  </tbody>
</table>

<h2 id="window-navigation--resize">Window Navigation &amp; Resize</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-h&gt;</code></td>
      <td>Move to left window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-j&gt;</code></td>
      <td>Move to window below</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-k&gt;</code></td>
      <td>Move to window above</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-l&gt;</code></td>
      <td>Move to right window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;C-S-Left&gt;</code></td>
      <td>Shrink window width by 5</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;C-S-Right&gt;</code></td>
      <td>Grow window width by 5</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;C-S-Up&gt;</code></td>
      <td>Grow window height by 5</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-u&gt;</code></td>
      <td>Half-page up (re-mapped from <code class="language-plaintext highlighter-rouge">&lt;C-u&gt;</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-d&gt;</code></td>
      <td>Half-page down (re-mapped from <code class="language-plaintext highlighter-rouge">&lt;C-d&gt;</code>)</td>
    </tr>
  </tbody>
</table>

<h2 id="visual-mode">Visual Mode</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&gt;</code> / <code class="language-plaintext highlighter-rouge">&lt;</code></td>
      <td>Indent / outdent and keep selection</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-j&gt;</code></td>
      <td>Move selection down</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;S-k&gt;</code></td>
      <td>Move selection up</td>
    </tr>
  </tbody>
</table>

<h2 id="telescope-fuzzy-finder">Telescope (Fuzzy Finder)</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;ff</code></td>
      <td>Find files (includes hidden)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;fg</code></td>
      <td>Live grep</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;fb</code></td>
      <td>List buffers</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;fl</code></td>
      <td>Fuzzy find in current buffer</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;fh</code></td>
      <td>Help tags</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;fv</code></td>
      <td>Git-tracked files</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;fk</code></td>
      <td>List keymaps</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;fp</code></td>
      <td>Planets (easter egg)</td>
    </tr>
  </tbody>
</table>

<h2 id="lsp-active-when-a-language-server-attaches">LSP (active when a language server attaches)</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gd</code></td>
      <td>Go to definition</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gD</code></td>
      <td>Go to declaration</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gr</code></td>
      <td>List references</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">gi</code></td>
      <td>Go to implementation</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">K</code></td>
      <td>Hover info</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;ca</code></td>
      <td>Code action</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;rn</code></td>
      <td>Rename symbol</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;d</code></td>
      <td>Show diagnostic in float</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">[d</code></td>
      <td>Previous diagnostic</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">]d</code></td>
      <td>Next diagnostic</td>
    </tr>
  </tbody>
</table>

<h2 id="file-explorer--startify">File Explorer &amp; Startify</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;e</code></td>
      <td>Toggle NvimTree (file explorer)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;S</code></td>
      <td>Open Startify dashboard</td>
    </tr>
  </tbody>
</table>

<p>Startify bookmarks:</p>

<table>
  <thead>
    <tr>
      <th>Key</th>
      <th>File</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">i</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/nvim/init.lua</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">p</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/nvim/lua/plugins.lua</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">c</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/nvim/lua/config.lua</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">o</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/nvim/lua/options.lua</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">f</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/fish/config.fish</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">g</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/git/gitconfig_global</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">t</code></td>
      <td><code class="language-plaintext highlighter-rouge">~/.config/tmux/tmux.conf</code></td>
    </tr>
  </tbody>
</table>

<h2 id="search--replace-spectre">Search &amp; Replace (Spectre)</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;sr</code></td>
      <td>Open Spectre (project-wide search/replace)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;sw</code></td>
      <td>Search current word under cursor</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;sp</code></td>
      <td>Search in current file only</td>
    </tr>
  </tbody>
</table>

<h2 id="git">Git</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;gb</code></td>
      <td>Toggle git blame for current line</td>
    </tr>
  </tbody>
</table>

<h2 id="peekup-register-preview">Peekup (Register Preview)</h2>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;p</code></td>
      <td>Paste from register, after cursor</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;Leader&gt;P</code></td>
      <td>Paste from register, before cursor</td>
    </tr>
  </tbody>
</table>

<h2 id="copilot-insert-mode">Copilot (Insert Mode)</h2>

<p>Tab is <em>not</em> used (<code class="language-plaintext highlighter-rouge">g.copilot_no_tab_map = true</code>).</p>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;C-J&gt;</code></td>
      <td>Accept suggestion</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;C-K&gt;</code></td>
      <td>Cancel suggestion</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;C-N&gt;</code></td>
      <td>Next suggestion</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">&lt;C-P&gt;</code></td>
      <td>Previous suggestion</td>
    </tr>
  </tbody>
</table>

<h2 id="useful-defaults-worth-remembering">Useful Defaults Worth Remembering</h2>

<ul>
  <li>Clipboard is unified with system (<code class="language-plaintext highlighter-rouge">clipboard=unnamedplus</code>) — yank/paste goes through <code class="language-plaintext highlighter-rouge">pbpaste</code>/<code class="language-plaintext highlighter-rouge">pbcopy</code>.</li>
  <li>New splits open below (<code class="language-plaintext highlighter-rouge">splitbelow</code>) and to the right (<code class="language-plaintext highlighter-rouge">splitright</code>).</li>
  <li>Smart case search — lowercase pattern matches case-insensitively, mixed case is sensitive.</li>
  <li>Trailing whitespace is stripped automatically before leaving insert mode.</li>
  <li>Yanked text briefly highlights.</li>
  <li>Filetype detection: <code class="language-plaintext highlighter-rouge">Fastfile</code> → ruby, <code class="language-plaintext highlighter-rouge">*.tfvars</code> → hcl.</li>
</ul>]]></content><author><name>Fernando Bass</name></author><category term="cheatsheets" /><category term="editor" /><category term="neovim" /><category term="vim" /><category term="telescope" /><category term="lsp" /><summary type="html"><![CDATA[Personal cheatsheet for my Neovim setup. Leader key is <Space]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">tmux Cheatsheet</title><link href="https://ferbass.xyz/2026/05/29/tmux-cheatsheet.html" rel="alternate" type="text/html" title="tmux Cheatsheet" /><published>2026-05-29T00:00:00+09:00</published><updated>2026-05-29T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/05/29/tmux-cheatsheet</id><content type="html" xml:base="https://ferbass.xyz/2026/05/29/tmux-cheatsheet.html"><![CDATA[<p>Personal cheatsheet for my tmux setup. Prefix is <code class="language-plaintext highlighter-rouge">Ctrl-b</code> (default).</p>

<blockquote>
  <p>Many shortcuts below use <code class="language-plaintext highlighter-rouge">Alt+key</code> — these require Alacritty’s
<code class="language-plaintext highlighter-rouge">window.option_as_alt = "OnlyLeft"</code>. Use the <strong>left Option key</strong>.
Right Option still types <code class="language-plaintext highlighter-rouge">≥ ñ © …</code>.</p>
</blockquote>

<h2 id="custom-bindings-my-config">Custom Bindings (My Config)</h2>

<h3 id="prefix-free-no-ctrl-b-needed">Prefix-Free (no <code class="language-plaintext highlighter-rouge">Ctrl-b</code> needed)</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+1</code> … <code class="language-plaintext highlighter-rouge">Alt+9</code></td>
      <td>Jump to window 1–9</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+h</code></td>
      <td>Move focus to pane on the left</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+j</code></td>
      <td>Move focus to pane below</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+k</code></td>
      <td>Move focus to pane above</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+l</code></td>
      <td>Move focus to pane on the right</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+c</code></td>
      <td>Create new window</td>
    </tr>
  </tbody>
</table>

<h3 id="prefix-based">Prefix-Based</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b b</code></td>
      <td>Previous window (overrides default which uses <code class="language-plaintext highlighter-rouge">p</code>)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b p</code></td>
      <td>Paste from system clipboard via <code class="language-plaintext highlighter-rouge">pbpaste</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">y</code> (copy-mode)</td>
      <td>Copy selection to system clipboard via <code class="language-plaintext highlighter-rouge">pbcopy</code></td>
    </tr>
  </tbody>
</table>

<h3 id="shell-shortcuts-fish">Shell Shortcuts (Fish)</h3>

<table>
  <thead>
    <tr>
      <th>Command</th>
      <th>Expands to</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tns &lt;name&gt;</code></td>
      <td><code class="language-plaintext highlighter-rouge">tmux new -d -s &lt;name&gt;</code> — create detached session</td>
    </tr>
  </tbody>
</table>

<h2 id="default-bindings-worth-knowing">Default Bindings Worth Knowing</h2>

<h3 id="sessions">Sessions</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tmux new -s &lt;name&gt;</code></td>
      <td>New named session (attached)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tns &lt;name&gt;</code></td>
      <td>New named session (detached, my alias)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tmux a -t &lt;name&gt;</code></td>
      <td>Attach to session by name</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tmux ls</code></td>
      <td>List sessions</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">tmux kill-session -t &lt;name&gt;</code></td>
      <td>Kill a session</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b d</code></td>
      <td>Detach from current session</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b s</code></td>
      <td>Interactive session list</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b $</code></td>
      <td>Rename current session</td>
    </tr>
  </tbody>
</table>

<h3 id="windows-tabs">Windows (tabs)</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+c</code> <em>(custom)</em></td>
      <td>New window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b c</code></td>
      <td>New window (default)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+1..9</code> <em>(custom)</em></td>
      <td>Jump to window N</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b ,</code></td>
      <td>Rename current window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b &amp;</code></td>
      <td>Kill current window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b n</code></td>
      <td>Next window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b b</code> <em>(custom)</em></td>
      <td>Previous window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b w</code></td>
      <td>Interactive window list</td>
    </tr>
  </tbody>
</table>

<h3 id="panes-splits-inside-a-window">Panes (splits inside a window)</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b "</code></td>
      <td>Split horizontally (new pane below)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b %</code></td>
      <td>Split vertically (new pane right)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Alt+h/j/k/l</code> <em>(custom)</em></td>
      <td>Move between panes (vim-style)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b x</code></td>
      <td>Kill current pane</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b z</code></td>
      <td>Zoom (toggle full-screen current pane)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b o</code></td>
      <td>Cycle through panes</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b q</code></td>
      <td>Show pane numbers (press a number to jump)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b !</code></td>
      <td>Convert pane into its own window</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b {</code> / <code class="language-plaintext highlighter-rouge">}</code></td>
      <td>Swap pane with prev/next</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b Space</code></td>
      <td>Cycle through pane layouts</td>
    </tr>
  </tbody>
</table>

<h3 id="resize-panes-hold-prefix">Resize Panes (hold prefix)</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b Ctrl-Up/Down/Left/Right</code></td>
      <td>Resize by 1 cell (hold to repeat)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b Alt-Up/Down/Left/Right</code></td>
      <td>Resize by 5 cells</td>
    </tr>
  </tbody>
</table>

<h3 id="copy-mode">Copy Mode</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b [</code></td>
      <td>Enter copy mode (scrollback / selection)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">q</code></td>
      <td>Leave copy mode</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Space</code></td>
      <td>Start selection</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Enter</code></td>
      <td>Copy selection (default), or <code class="language-plaintext highlighter-rouge">y</code> (vi mode)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">y</code> <em>(custom)</em></td>
      <td>Copy selection AND pipe to <code class="language-plaintext highlighter-rouge">pbcopy</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/</code> <code class="language-plaintext highlighter-rouge">?</code></td>
      <td>Forward / backward search</td>
    </tr>
  </tbody>
</table>

<h3 id="misc">Misc</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b ?</code></td>
      <td>Show all keybindings</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b :</code></td>
      <td>Command prompt</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b t</code></td>
      <td>Big clock</td>
    </tr>
  </tbody>
</table>

<h2 id="plugins-tpm">Plugins (TPM)</h2>

<p>Installed via <code class="language-plaintext highlighter-rouge">tmux-plugins/tpm</code>:</p>

<ul>
  <li><strong>tmux-resurrect</strong> — save / restore session state.</li>
  <li><strong>tmux-continuum</strong> — auto-save every 15 minutes, restore on start.</li>
</ul>

<h3 id="tpm-controls">TPM controls</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b I</code></td>
      <td>Install plugins listed in <code class="language-plaintext highlighter-rouge">tmux.conf</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b U</code></td>
      <td>Update plugins</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b alt+u</code></td>
      <td>Uninstall removed plugins</td>
    </tr>
  </tbody>
</table>

<h3 id="resurrect">Resurrect</h3>

<table>
  <thead>
    <tr>
      <th>Keys</th>
      <th>Action</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b Ctrl-s</code></td>
      <td>Save environment</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Ctrl-b Ctrl-r</code></td>
      <td>Restore last save</td>
    </tr>
  </tbody>
</table>

<h2 id="server-wide-settings-reference">Server-Wide Settings (Reference)</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">default-shell</code> → <code class="language-plaintext highlighter-rouge">/opt/homebrew/bin/fish</code></li>
  <li><code class="language-plaintext highlighter-rouge">default-terminal</code> → <code class="language-plaintext highlighter-rouge">xterm-256color</code></li>
  <li><code class="language-plaintext highlighter-rouge">escape-time</code> → <code class="language-plaintext highlighter-rouge">10ms</code> (low so Alt sequences feel snappy)</li>
  <li><code class="language-plaintext highlighter-rouge">mouse</code> → on (click panes/windows, drag to resize)</li>
  <li><code class="language-plaintext highlighter-rouge">automatic-rename</code> → on; format shows current directory basename, with optional AI tool prefix.</li>
</ul>

<h2 id="reloading-config">Reloading Config</h2>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>tmux <span class="nb">source</span> ~/.config/tmux/tmux.conf   <span class="c"># soft reload</span>
tmux kill-server                        <span class="c"># hard reload (kills all sessions)</span>
</code></pre></div></div>]]></content><author><name>Fernando Bass</name></author><category term="cheatsheets" /><category term="terminal" /><category term="tmux" /><category term="alacritty" /><summary type="html"><![CDATA[Personal cheatsheet for my tmux setup. Prefix is Ctrl-b]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Clean HTTPS URLs for Your Home Server Services</title><link href="https://ferbass.xyz/2026/05/28/local-services-with-pihole-npm-letsencrypt.html" rel="alternate" type="text/html" title="Clean HTTPS URLs for Your Home Server Services" /><published>2026-05-28T00:00:00+09:00</published><updated>2026-05-28T00:00:00+09:00</updated><id>https://ferbass.xyz/2026/05/28/local-services-with-pihole-npm-letsencrypt</id><content type="html" xml:base="https://ferbass.xyz/2026/05/28/local-services-with-pihole-npm-letsencrypt.html"><![CDATA[<p>Running self-hosted services at home is great until you have to remember that Sonarr is on port 8989, Radarr is on 7878, Prowlarr is on 9696, and so on. Typing <code class="language-plaintext highlighter-rouge">192.168.50.116:8989</code> every time gets old fast. And good luck getting HTTPS to work, which means your browser yells at you or your password manager refuses to save credentials.</p>

<p>This post covers how to get from that situation to having clean URLs like <code class="language-plaintext highlighter-rouge">https://sonarr.yourdomain.com</code> that work on every device in your house, with a real trusted HTTPS certificate.</p>

<h2 id="what-you-need">What You Need</h2>

<ul>
  <li>A domain name (something like <code class="language-plaintext highlighter-rouge">yourdomain.com</code>)</li>
  <li>Pi-hole running on your home network</li>
  <li>Nginx Proxy Manager (NPM) running as a Docker container</li>
  <li>A DNS provider with an API (this guide uses Google Cloud DNS, but Cloudflare works just as well)</li>
</ul>

<p>The idea is simple: Pi-hole handles local DNS so your devices know that <code class="language-plaintext highlighter-rouge">sonarr.yourdomain.com</code> points to your server’s local IP. NPM sits on port 80/443 and routes incoming requests to the right service based on the hostname. Let’s Encrypt handles the certificate via DNS challenge so you get real HTTPS without exposing your server to the internet.</p>

<h2 id="step-1-set-up-pi-hole-local-dns">Step 1: Set Up Pi-hole Local DNS</h2>

<p>Pi-hole v6 stores custom DNS entries in <code class="language-plaintext highlighter-rouge">/etc/pihole/pihole.toml</code> under the <code class="language-plaintext highlighter-rouge">dns.hosts</code> array. If you are running Pi-hole in Docker, that path is inside your mounted volume.</p>

<p>Open your <code class="language-plaintext highlighter-rouge">pihole.toml</code> and find the <code class="language-plaintext highlighter-rouge">[dns]</code> section. Add your entries to the <code class="language-plaintext highlighter-rouge">hosts</code> field:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">dns</span><span class="k">]</span>
  <span class="n">hosts</span> <span class="o">=</span><span class="w"> </span><span class="p">[</span>
    <span class="s">"192.168.50.116 sonarr.yourdomain.com"</span><span class="p">,</span>
    <span class="s">"192.168.50.116 radarr.yourdomain.com"</span><span class="p">,</span>
    <span class="s">"192.168.50.116 bazarr.yourdomain.com"</span><span class="p">,</span>
    <span class="s">"192.168.50.116 prowlarr.yourdomain.com"</span><span class="p">,</span>
    <span class="s">"192.168.50.116 pihole.yourdomain.com"</span><span class="p">,</span>
    <span class="s">"192.168.50.116 portainer.yourdomain.com"</span><span class="p">,</span>
    <span class="s">"192.168.50.116 qt.yourdomain.com"</span><span class="p">,</span>
    <span class="s">"192.168.50.116 npm.yourdomain.com"</span>
  <span class="p">]</span> <span class="c">### CHANGED, default = []</span>
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">192.168.50.116</code> with your server’s actual local IP. After saving, restart Pi-hole:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker restart pihole
</code></pre></div></div>

<p>Make sure your router uses Pi-hole as its DNS server so all devices on your network pick up these custom entries automatically.</p>

<h3 id="a-note-on-pi-hole-v6">A Note on Pi-hole v6</h3>

<p>If you are upgrading from Pi-hole v5, the old <code class="language-plaintext highlighter-rouge">custom.list</code> file is no longer read by v6. You have to use the <code class="language-plaintext highlighter-rouge">pihole.toml</code> approach above. The <code class="language-plaintext highlighter-rouge">custom.list</code> file will silently be ignored.</p>

<p>Also, set <code class="language-plaintext highlighter-rouge">listeningMode</code> to <code class="language-plaintext highlighter-rouge">ALL</code> in pihole.toml so Pi-hole accepts queries from all your local devices:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">listeningMode</span> <span class="o">=</span><span class="w"> </span><span class="s">"ALL"</span>
</code></pre></div></div>

<p>If you run Pi-hole in Docker with bridge networking, you may hit an issue where the Docker UDP proxy does not properly relay DNS queries from external clients. The fix is to run Pi-hole with host networking:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--name</span> pihole <span class="se">\</span>
  <span class="nt">--network</span> host <span class="se">\</span>
  <span class="nt">--restart</span> unless-stopped <span class="se">\</span>
  <span class="nt">-e</span> <span class="nv">TZ</span><span class="o">=</span>America/New_York <span class="se">\</span>
  <span class="nt">-e</span> <span class="nv">WEBPASSWORD</span><span class="o">=</span>yourpassword <span class="se">\</span>
  <span class="nt">-v</span> /path/to/pihole/etc-pihole:/etc/pihole <span class="se">\</span>
  <span class="nt">-v</span> /path/to/pihole/etc-dnsmasq.d:/etc/dnsmasq.d <span class="se">\</span>
  pihole/pihole:latest
</code></pre></div></div>

<p>With host networking, Pi-hole binds directly to your server’s network interface and DNS works correctly from all devices.</p>

<p>If you switch Pi-hole to host networking, its web interface will try to use port 80, which may conflict with NPM. Change the web port in <code class="language-plaintext highlighter-rouge">pihole.toml</code> first:</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">webserver</span><span class="k">]</span>
  <span class="n">port</span> <span class="o">=</span><span class="w"> </span><span class="s">"8800o"</span>
</code></pre></div></div>

<h2 id="step-2-set-up-nginx-proxy-manager">Step 2: Set Up Nginx Proxy Manager</h2>

<p>NPM is the reverse proxy that sits in front of all your services. It accepts requests on ports 80 and 443 and routes them to the right backend based on the domain name in the request.</p>

<p>One gotcha: if you run Tailscale on the same machine, it binds to all interfaces including <code class="language-plaintext highlighter-rouge">[::]:443</code>, which blocks NPM from binding to <code class="language-plaintext highlighter-rouge">0.0.0.0:443</code>. The fix is to bind NPM specifically to your LAN IP:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-d</span> <span class="se">\</span>
  <span class="nt">--name</span> nginxproxymanager <span class="se">\</span>
  <span class="nt">--restart</span> unless-stopped <span class="se">\</span>
  <span class="nt">-p</span> 0.0.0.0:80:80 <span class="se">\</span>
  <span class="nt">-p</span> 0.0.0.0:81:81 <span class="se">\</span>
  <span class="nt">-p</span> 192.168.50.116:443:443 <span class="se">\</span>
  <span class="nt">-v</span> /path/to/npm/data:/data <span class="se">\</span>
  <span class="nt">-v</span> /path/to/npm/letsencrypt:/etc/letsencrypt <span class="se">\</span>
  jc21/nginx-proxy-manager:latest
</code></pre></div></div>

<p>Replace <code class="language-plaintext highlighter-rouge">192.168.50.116</code> with your server’s LAN IP. Access the NPM admin UI at <code class="language-plaintext highlighter-rouge">http://your-server-ip:81</code>.</p>

<h2 id="step-3-get-a-wildcard-lets-encrypt-certificate">Step 3: Get a Wildcard Let’s Encrypt Certificate</h2>

<p>Here is the part that makes everything work on every device without installing any custom certificates. Let’s Encrypt can issue a wildcard cert for <code class="language-plaintext highlighter-rouge">*.yourdomain.com</code> using a DNS challenge, where it asks you to prove ownership by adding a TXT record to your domain’s public DNS.</p>

<p>The catch with <code class="language-plaintext highlighter-rouge">.dev</code> domains (and several others like <code class="language-plaintext highlighter-rouge">.app</code>) is that browsers enforce HTTPS for them no matter what. This is called HSTS preloading. So you cannot use plain HTTP even for local services. You need a real certificate.</p>

<h3 id="set-up-gcp-cloud-dns">Set Up GCP Cloud DNS</h3>

<p>This guide uses Google Cloud DNS, but the same approach works with Cloudflare or any DNS provider that has an API.</p>

<ol>
  <li>Create a public zone for your domain in the <a href="https://console.cloud.google.com/net-services/dns/zones">GCP Cloud DNS console</a>.</li>
  <li>Note the four nameservers GCP assigns (they look like <code class="language-plaintext highlighter-rouge">ns-cloud-c1.googledomains.com</code>).</li>
  <li>In your domain registrar, update the nameservers to the four GCP ones.</li>
</ol>

<p>Wait a few minutes for propagation, then verify:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>dig NS yourdomain.com @8.8.8.8
</code></pre></div></div>

<h3 id="create-a-service-account-for-dns-challenge">Create a Service Account for DNS Challenge</h3>

<p>acme.sh needs permission to temporarily add TXT records to your domain during certificate issuance.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create service account</span>
gcloud iam service-accounts create acme-dns-challenge <span class="se">\</span>
  <span class="nt">--display-name</span><span class="o">=</span><span class="s2">"ACME DNS Challenge"</span> <span class="se">\</span>
  <span class="nt">--project</span><span class="o">=</span>your-project-id

<span class="c"># Grant DNS Admin role</span>
gcloud projects add-iam-policy-binding your-project-id <span class="se">\</span>
  <span class="nt">--member</span><span class="o">=</span><span class="s2">"serviceAccount:acme-dns-challenge@your-project-id.iam.gserviceaccount.com"</span> <span class="se">\</span>
  <span class="nt">--role</span><span class="o">=</span><span class="s2">"roles/dns.admin"</span>

<span class="c"># Download the key</span>
gcloud iam service-accounts keys create ~/gcp-dns-key.json <span class="se">\</span>
  <span class="nt">--iam-account</span><span class="o">=</span>acme-dns-challenge@your-project-id.iam.gserviceaccount.com <span class="se">\</span>
  <span class="nt">--project</span><span class="o">=</span>your-project-id
</code></pre></div></div>

<p>Copy the key to your server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scp ~/gcp-dns-key.json user@your-server:/home/user/gcp-dns-key.json
</code></pre></div></div>

<h3 id="issue-the-certificate-with-acmesh">Issue the Certificate with acme.sh</h3>

<p>Install acme.sh on your server:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl https://get.acme.sh | sh <span class="nt">-s</span> <span class="nv">email</span><span class="o">=</span>you@example.com
</code></pre></div></div>

<p>Since the built-in <code class="language-plaintext highlighter-rouge">dns_gcloud</code> plugin requires the <code class="language-plaintext highlighter-rouge">gcloud</code> CLI, you can write a small Python helper that calls the GCP Cloud DNS API directly using your service account key. Save this as <code class="language-plaintext highlighter-rouge">/home/user/gcp_dns_helper.py</code>:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">#!/usr/bin/env python3
</span><span class="kn">import</span> <span class="n">json</span><span class="p">,</span> <span class="n">base64</span><span class="p">,</span> <span class="n">time</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">,</span> <span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">,</span> <span class="n">sys</span>
<span class="kn">from</span> <span class="n">cryptography.hazmat.primitives</span> <span class="kn">import</span> <span class="n">hashes</span><span class="p">,</span> <span class="n">serialization</span>
<span class="kn">from</span> <span class="n">cryptography.hazmat.primitives.asymmetric</span> <span class="kn">import</span> <span class="n">padding</span>
<span class="kn">from</span> <span class="n">cryptography.hazmat.backends</span> <span class="kn">import</span> <span class="n">default_backend</span>

<span class="n">KEY_FILE</span> <span class="o">=</span> <span class="sh">"</span><span class="s">/home/user/gcp-dns-key.json</span><span class="sh">"</span>

<span class="k">def</span> <span class="nf">get_token</span><span class="p">():</span>
    <span class="n">key</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="nf">open</span><span class="p">(</span><span class="n">KEY_FILE</span><span class="p">))</span>
    <span class="n">header</span> <span class="o">=</span> <span class="n">base64</span><span class="p">.</span><span class="nf">urlsafe_b64encode</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span><span class="sh">"</span><span class="s">alg</span><span class="sh">"</span><span class="p">:</span><span class="sh">"</span><span class="s">RS256</span><span class="sh">"</span><span class="p">,</span><span class="sh">"</span><span class="s">typ</span><span class="sh">"</span><span class="p">:</span><span class="sh">"</span><span class="s">JWT</span><span class="sh">"</span><span class="p">}).</span><span class="nf">encode</span><span class="p">()).</span><span class="nf">rstrip</span><span class="p">(</span><span class="sa">b</span><span class="sh">"</span><span class="s">=</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">now</span> <span class="o">=</span> <span class="nf">int</span><span class="p">(</span><span class="n">time</span><span class="p">.</span><span class="nf">time</span><span class="p">())</span>
    <span class="n">claims</span> <span class="o">=</span> <span class="n">base64</span><span class="p">.</span><span class="nf">urlsafe_b64encode</span><span class="p">(</span><span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">({</span>
        <span class="sh">"</span><span class="s">iss</span><span class="sh">"</span><span class="p">:</span> <span class="n">key</span><span class="p">[</span><span class="sh">"</span><span class="s">client_email</span><span class="sh">"</span><span class="p">],</span>
        <span class="sh">"</span><span class="s">scope</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">https://www.googleapis.com/auth/cloud-platform</span><span class="sh">"</span><span class="p">,</span>
        <span class="sh">"</span><span class="s">aud</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">https://oauth2.googleapis.com/token</span><span class="sh">"</span><span class="p">,</span>
        <span class="sh">"</span><span class="s">exp</span><span class="sh">"</span><span class="p">:</span> <span class="n">now</span><span class="o">+</span><span class="mi">3600</span><span class="p">,</span> <span class="sh">"</span><span class="s">iat</span><span class="sh">"</span><span class="p">:</span> <span class="n">now</span>
    <span class="p">}).</span><span class="nf">encode</span><span class="p">()).</span><span class="nf">rstrip</span><span class="p">(</span><span class="sa">b</span><span class="sh">"</span><span class="s">=</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">msg</span> <span class="o">=</span> <span class="n">header</span> <span class="o">+</span> <span class="sa">b</span><span class="sh">"</span><span class="s">.</span><span class="sh">"</span> <span class="o">+</span> <span class="n">claims</span>
    <span class="n">priv</span> <span class="o">=</span> <span class="n">serialization</span><span class="p">.</span><span class="nf">load_pem_private_key</span><span class="p">(</span><span class="n">key</span><span class="p">[</span><span class="sh">"</span><span class="s">private_key</span><span class="sh">"</span><span class="p">].</span><span class="nf">encode</span><span class="p">(),</span> <span class="n">password</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span> <span class="n">backend</span><span class="o">=</span><span class="nf">default_backend</span><span class="p">())</span>
    <span class="n">sig</span> <span class="o">=</span> <span class="n">base64</span><span class="p">.</span><span class="nf">urlsafe_b64encode</span><span class="p">(</span><span class="n">priv</span><span class="p">.</span><span class="nf">sign</span><span class="p">(</span><span class="n">msg</span><span class="p">,</span> <span class="n">padding</span><span class="p">.</span><span class="nc">PKCS1v15</span><span class="p">(),</span> <span class="n">hashes</span><span class="p">.</span><span class="nc">SHA256</span><span class="p">())).</span><span class="nf">rstrip</span><span class="p">(</span><span class="sa">b</span><span class="sh">"</span><span class="s">=</span><span class="sh">"</span><span class="p">)</span>
    <span class="n">jwt</span> <span class="o">=</span> <span class="n">msg</span> <span class="o">+</span> <span class="sa">b</span><span class="sh">"</span><span class="s">.</span><span class="sh">"</span> <span class="o">+</span> <span class="n">sig</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">.</span><span class="nf">urlencode</span><span class="p">({</span><span class="sh">"</span><span class="s">grant_type</span><span class="sh">"</span><span class="p">:</span><span class="sh">"</span><span class="s">urn:ietf:params:oauth:grant-type:jwt-bearer</span><span class="sh">"</span><span class="p">,</span><span class="sh">"</span><span class="s">assertion</span><span class="sh">"</span><span class="p">:</span><span class="n">jwt</span><span class="p">.</span><span class="nf">decode</span><span class="p">()}).</span><span class="nf">encode</span><span class="p">()</span>
    <span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nc">Request</span><span class="p">(</span><span class="sh">"</span><span class="s">https://oauth2.googleapis.com/token</span><span class="sh">"</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">json</span><span class="p">.</span><span class="nf">loads</span><span class="p">(</span><span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nf">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">).</span><span class="nf">read</span><span class="p">())[</span><span class="sh">"</span><span class="s">access_token</span><span class="sh">"</span><span class="p">]</span>

<span class="k">def</span> <span class="nf">api</span><span class="p">(</span><span class="n">method</span><span class="p">,</span> <span class="n">url</span><span class="p">,</span> <span class="n">token</span><span class="p">,</span> <span class="n">body</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
    <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span><span class="n">body</span><span class="p">).</span><span class="nf">encode</span><span class="p">()</span> <span class="k">if</span> <span class="n">body</span> <span class="k">else</span> <span class="bp">None</span>
    <span class="n">req</span> <span class="o">=</span> <span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nc">Request</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="n">data</span><span class="o">=</span><span class="n">data</span><span class="p">,</span> <span class="n">method</span><span class="o">=</span><span class="n">method</span><span class="p">,</span>
        <span class="n">headers</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">Authorization</span><span class="sh">"</span><span class="p">:</span><span class="sa">f</span><span class="sh">"</span><span class="s">Bearer </span><span class="si">{</span><span class="n">token</span><span class="si">}</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Content-Type</span><span class="sh">"</span><span class="p">:</span><span class="sh">"</span><span class="s">application/json</span><span class="sh">"</span><span class="p">})</span>
    <span class="k">try</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">json</span><span class="p">.</span><span class="nf">loads</span><span class="p">(</span><span class="n">urllib</span><span class="p">.</span><span class="n">request</span><span class="p">.</span><span class="nf">urlopen</span><span class="p">(</span><span class="n">req</span><span class="p">).</span><span class="nf">read</span><span class="p">())</span>
    <span class="k">except</span> <span class="n">urllib</span><span class="p">.</span><span class="n">error</span><span class="p">.</span><span class="n">HTTPError</span> <span class="k">as</span> <span class="n">e</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">json</span><span class="p">.</span><span class="nf">loads</span><span class="p">(</span><span class="n">e</span><span class="p">.</span><span class="nf">read</span><span class="p">())</span>

<span class="k">def</span> <span class="nf">find_zone</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">project</span><span class="p">,</span> <span class="n">domain</span><span class="p">):</span>
    <span class="n">r</span> <span class="o">=</span> <span class="nf">api</span><span class="p">(</span><span class="sh">"</span><span class="s">GET</span><span class="sh">"</span><span class="p">,</span> <span class="sa">f</span><span class="sh">"</span><span class="s">https://dns.googleapis.com/dns/v1/projects/</span><span class="si">{</span><span class="n">project</span><span class="si">}</span><span class="s">/managedZones</span><span class="sh">"</span><span class="p">,</span> <span class="n">token</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">z</span> <span class="ow">in</span> <span class="n">r</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">managedZones</span><span class="sh">"</span><span class="p">,</span> <span class="p">[]):</span>
        <span class="n">dns</span> <span class="o">=</span> <span class="n">z</span><span class="p">[</span><span class="sh">"</span><span class="s">dnsName</span><span class="sh">"</span><span class="p">].</span><span class="nf">rstrip</span><span class="p">(</span><span class="sh">"</span><span class="s">.</span><span class="sh">"</span><span class="p">)</span>
        <span class="k">if</span> <span class="n">domain</span> <span class="o">==</span> <span class="n">dns</span> <span class="ow">or</span> <span class="n">domain</span><span class="p">.</span><span class="nf">endswith</span><span class="p">(</span><span class="sh">"</span><span class="s">.</span><span class="sh">"</span><span class="o">+</span><span class="n">dns</span><span class="p">):</span>
            <span class="k">return</span> <span class="n">z</span><span class="p">[</span><span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">]</span>
    <span class="k">return</span> <span class="bp">None</span>

<span class="k">def</span> <span class="nf">set_txt</span><span class="p">(</span><span class="n">action</span><span class="p">,</span> <span class="n">domain</span><span class="p">,</span> <span class="n">value</span><span class="p">):</span>
    <span class="n">key</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">load</span><span class="p">(</span><span class="nf">open</span><span class="p">(</span><span class="n">KEY_FILE</span><span class="p">))</span>
    <span class="n">project</span> <span class="o">=</span> <span class="n">key</span><span class="p">[</span><span class="sh">"</span><span class="s">project_id</span><span class="sh">"</span><span class="p">]</span>
    <span class="n">token</span> <span class="o">=</span> <span class="nf">get_token</span><span class="p">()</span>
    <span class="n">zone</span> <span class="o">=</span> <span class="nf">find_zone</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">project</span><span class="p">,</span> <span class="n">domain</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">zone</span><span class="p">:</span>
        <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">ERROR: no zone found for </span><span class="si">{</span><span class="n">domain</span><span class="si">}</span><span class="sh">"</span><span class="p">,</span> <span class="nb">file</span><span class="o">=</span><span class="n">sys</span><span class="p">.</span><span class="n">stderr</span><span class="p">);</span> <span class="n">sys</span><span class="p">.</span><span class="nf">exit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>

    <span class="n">base_url</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="s">https://dns.googleapis.com/dns/v1/projects/</span><span class="si">{</span><span class="n">project</span><span class="si">}</span><span class="s">/managedZones/</span><span class="si">{</span><span class="n">zone</span><span class="si">}</span><span class="sh">"</span>
    <span class="n">fqdn</span> <span class="o">=</span> <span class="n">domain</span> <span class="k">if</span> <span class="n">domain</span><span class="p">.</span><span class="nf">endswith</span><span class="p">(</span><span class="sh">"</span><span class="s">.</span><span class="sh">"</span><span class="p">)</span> <span class="k">else</span> <span class="n">domain</span><span class="o">+</span><span class="sh">"</span><span class="s">.</span><span class="sh">"</span>

    <span class="k">try</span><span class="p">:</span>
        <span class="n">existing</span> <span class="o">=</span> <span class="nf">api</span><span class="p">(</span><span class="sh">"</span><span class="s">GET</span><span class="sh">"</span><span class="p">,</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">base_url</span><span class="si">}</span><span class="s">/rrsets/</span><span class="si">{</span><span class="n">urllib</span><span class="p">.</span><span class="n">parse</span><span class="p">.</span><span class="nf">quote</span><span class="p">(</span><span class="n">fqdn</span><span class="p">)</span><span class="si">}</span><span class="s">/TXT</span><span class="sh">"</span><span class="p">,</span> <span class="n">token</span><span class="p">)</span>
        <span class="n">old_rrdatas</span> <span class="o">=</span> <span class="n">existing</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">"</span><span class="s">rrdatas</span><span class="sh">"</span><span class="p">,</span> <span class="p">[])</span>
    <span class="k">except</span><span class="p">:</span>
        <span class="n">old_rrdatas</span> <span class="o">=</span> <span class="p">[]</span>

    <span class="n">quoted</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">'"</span><span class="si">{</span><span class="n">value</span><span class="si">}</span><span class="sh">"'</span>
    <span class="n">new_rrdatas</span> <span class="o">=</span> <span class="nf">list</span><span class="p">(</span><span class="nf">set</span><span class="p">(</span><span class="n">old_rrdatas</span> <span class="o">+</span> <span class="p">[</span><span class="n">quoted</span><span class="p">]))</span> <span class="k">if</span> <span class="n">action</span> <span class="o">==</span> <span class="sh">"</span><span class="s">add</span><span class="sh">"</span> <span class="k">else</span> <span class="p">[</span><span class="n">r</span> <span class="k">for</span> <span class="n">r</span> <span class="ow">in</span> <span class="n">old_rrdatas</span> <span class="k">if</span> <span class="n">r</span> <span class="o">!=</span> <span class="n">quoted</span><span class="p">]</span>

    <span class="n">change</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">additions</span><span class="sh">"</span><span class="p">:</span> <span class="p">[],</span> <span class="sh">"</span><span class="s">deletions</span><span class="sh">"</span><span class="p">:</span> <span class="p">[]}</span>
    <span class="k">if</span> <span class="n">old_rrdatas</span><span class="p">:</span>
        <span class="n">change</span><span class="p">[</span><span class="sh">"</span><span class="s">deletions</span><span class="sh">"</span><span class="p">].</span><span class="nf">append</span><span class="p">({</span><span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="n">fqdn</span><span class="p">,</span> <span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">TXT</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">ttl</span><span class="sh">"</span><span class="p">:</span> <span class="mi">60</span><span class="p">,</span> <span class="sh">"</span><span class="s">rrdatas</span><span class="sh">"</span><span class="p">:</span> <span class="n">old_rrdatas</span><span class="p">})</span>
    <span class="k">if</span> <span class="n">new_rrdatas</span><span class="p">:</span>
        <span class="n">change</span><span class="p">[</span><span class="sh">"</span><span class="s">additions</span><span class="sh">"</span><span class="p">].</span><span class="nf">append</span><span class="p">({</span><span class="sh">"</span><span class="s">name</span><span class="sh">"</span><span class="p">:</span> <span class="n">fqdn</span><span class="p">,</span> <span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">TXT</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">ttl</span><span class="sh">"</span><span class="p">:</span> <span class="mi">60</span><span class="p">,</span> <span class="sh">"</span><span class="s">rrdatas</span><span class="sh">"</span><span class="p">:</span> <span class="n">new_rrdatas</span><span class="p">})</span>

    <span class="n">result</span> <span class="o">=</span> <span class="nf">api</span><span class="p">(</span><span class="sh">"</span><span class="s">POST</span><span class="sh">"</span><span class="p">,</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">base_url</span><span class="si">}</span><span class="s">/changes</span><span class="sh">"</span><span class="p">,</span> <span class="n">token</span><span class="p">,</span> <span class="n">change</span><span class="p">)</span>
    <span class="nf">print</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="s">Change status: </span><span class="si">{</span><span class="n">result</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="sh">'</span><span class="s">status</span><span class="sh">'</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>

<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="sh">"</span><span class="s">__main__</span><span class="sh">"</span><span class="p">:</span>
    <span class="nf">set_txt</span><span class="p">(</span><span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">2</span><span class="p">],</span> <span class="n">sys</span><span class="p">.</span><span class="n">argv</span><span class="p">[</span><span class="mi">3</span><span class="p">])</span>
</code></pre></div></div>

<p>Then create a custom acme.sh DNS plugin at <code class="language-plaintext highlighter-rouge">~/.acme.sh/dnsapi/dns_gcpcloud.sh</code>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
dns_gcpcloud_add<span class="o">()</span> <span class="o">{</span>
  <span class="nv">fulldomain</span><span class="o">=</span><span class="nv">$1</span><span class="p">;</span> <span class="nv">txtvalue</span><span class="o">=</span><span class="nv">$2</span>
  python3 /home/user/gcp_dns_helper.py add <span class="s2">"</span><span class="nv">$fulldomain</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$txtvalue</span><span class="s2">"</span>
<span class="o">}</span>
dns_gcpcloud_rm<span class="o">()</span> <span class="o">{</span>
  <span class="nv">fulldomain</span><span class="o">=</span><span class="nv">$1</span><span class="p">;</span> <span class="nv">txtvalue</span><span class="o">=</span><span class="nv">$2</span>
  python3 /home/user/gcp_dns_helper.py remove <span class="s2">"</span><span class="nv">$fulldomain</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$txtvalue</span><span class="s2">"</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">chmod</span> +x ~/.acme.sh/dnsapi/dns_gcpcloud.sh
</code></pre></div></div>

<p>Now issue the wildcard certificate:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>~/.acme.sh/acme.sh <span class="nt">--issue</span> <span class="se">\</span>
  <span class="nt">--dns</span> dns_gcpcloud <span class="se">\</span>
  <span class="nt">-d</span> <span class="s2">"yourdomain.com"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s2">"*.yourdomain.com"</span> <span class="se">\</span>
  <span class="nt">--server</span> letsencrypt <span class="se">\</span>
  <span class="nt">--dnssleep</span> 30
</code></pre></div></div>

<p>acme.sh will add a TXT record, wait 30 seconds for propagation, Let’s Encrypt verifies it, and the cert is issued. The TXT record is removed automatically after.</p>

<p>Certs live in <code class="language-plaintext highlighter-rouge">~/.acme.sh/yourdomain.com_ecc/</code>.</p>

<h2 id="step-4-upload-the-certificate-to-npm-and-add-proxy-hosts">Step 4: Upload the Certificate to NPM and Add Proxy Hosts</h2>

<p>In the NPM admin UI at <code class="language-plaintext highlighter-rouge">http://your-server-ip:81</code>:</p>

<ol>
  <li>Go to <strong>SSL Certificates</strong> and add a custom certificate using the files from <code class="language-plaintext highlighter-rouge">~/.acme.sh/yourdomain.com_ecc/</code>:
    <ul>
      <li>Certificate: <code class="language-plaintext highlighter-rouge">fullchain.cer</code></li>
      <li>Private Key: <code class="language-plaintext highlighter-rouge">yourdomain.com.key</code></li>
      <li>Intermediate: <code class="language-plaintext highlighter-rouge">ca.cer</code></li>
    </ul>
  </li>
  <li>Go to <strong>Proxy Hosts</strong> and add one entry per service. For each one:
    <ul>
      <li>Domain: <code class="language-plaintext highlighter-rouge">sonarr.yourdomain.com</code></li>
      <li>Scheme: <code class="language-plaintext highlighter-rouge">http</code></li>
      <li>Forward Host: <code class="language-plaintext highlighter-rouge">127.0.0.1</code> (or your server’s LAN IP)</li>
      <li>Forward Port: <code class="language-plaintext highlighter-rouge">8989</code></li>
      <li>SSL tab: select your wildcard certificate, enable <strong>Force SSL</strong></li>
    </ul>
  </li>
</ol>

<p>Repeat for each service with its correct port:</p>

<table>
  <thead>
    <tr>
      <th>Domain</th>
      <th>Port</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>sonarr.yourdomain.com</td>
      <td>8989</td>
    </tr>
    <tr>
      <td>radarr.yourdomain.com</td>
      <td>7878</td>
    </tr>
    <tr>
      <td>bazarr.yourdomain.com</td>
      <td>6767</td>
    </tr>
    <tr>
      <td>prowlarr.yourdomain.com</td>
      <td>9696</td>
    </tr>
    <tr>
      <td>pihole.yourdomain.com</td>
      <td>8800</td>
    </tr>
    <tr>
      <td>portainer.yourdomain.com</td>
      <td>9000</td>
    </tr>
    <tr>
      <td>qt.yourdomain.com</td>
      <td>8181</td>
    </tr>
    <tr>
      <td>npm.yourdomain.com</td>
      <td>81</td>
    </tr>
  </tbody>
</table>

<h2 id="auto-renewal">Auto-Renewal</h2>

<p>acme.sh installs a cron job during setup that checks for renewal daily. Certificates are renewed automatically about 30 days before expiry. You will want a deploy hook that pushes the renewed cert to NPM when renewal happens. The NPM API supports uploading custom certificates, so you can script this part and register it as an acme.sh deploy hook.</p>

<h2 id="result">Result</h2>

<p>Every device on your local network can now reach your services by name:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">https://sonarr.yourdomain.com</code></li>
  <li><code class="language-plaintext highlighter-rouge">https://radarr.yourdomain.com</code></li>
  <li><code class="language-plaintext highlighter-rouge">https://pihole.yourdomain.com</code></li>
</ul>

<p>No port numbers, no certificate warnings, no installing custom CAs on each device. Works in any browser, on any operating system, including phones and tablets.</p>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="homelab" /><category term="networking" /><category term="pihole" /><category term="nginx-proxy-manager" /><category term="letsencrypt" /><category term="dns" /><category term="homelab" /><category term="self-hosted" /><summary type="html"><![CDATA[Running self-hosted services at home is great until you have to remember that Sonarr is on port 8989, Radarr is on 7878, Prowlarr is on 9696, and so on. Typing...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Privacidade: O preço da civilização?</title><link href="https://ferbass.xyz/2026/03/18/privacidade-o-pre%C3%A7o-da-civiliza%C3%A7%C3%A3o.html" rel="alternate" type="text/html" title="Privacidade: O preço da civilização?" /><published>2026-03-18T10:54:00+09:00</published><updated>2026-03-18T10:54:00+09:00</updated><id>https://ferbass.xyz/2026/03/18/privacidade-o-pre%C3%A7o-da-civiliza%C3%A7%C3%A3o</id><content type="html" xml:base="https://ferbass.xyz/2026/03/18/privacidade-o-pre%C3%A7o-da-civiliza%C3%A7%C3%A3o.html"><![CDATA[<p>Estou relendo o livro <strong>“Limites da Fundação”</strong>, de Isaac Asimov, e me deparei com alguns trechos interessantes que eu não lembrava desde a primeira vez que li, há 20 anos. Gostaria de compartilhar essa reflexão aqui.</p>

<p>O diálogo ocorre entre <strong>Golan Trevize</strong> e <strong>Janov Pelorat</strong> nos primeiros momentos de sua jornada interestelar. Trevize se preocupa com a possibilidade de a nave estar equipada com um hipertransmissor, o que comprometeria a privacidade deles.</p>

<p>Primeiro, Pelorat pergunta:</p>

<blockquote>
  <p>[…]</p>

  <p>— Ah, entendo. Quero dizer, não entendo. O que é um hipertransmissor?</p>
</blockquote>

<p>Trevize responde de forma direta:</p>

<blockquote>
  <p>[…]</p>

  <p>— Bom, deixe-me explicar, Janov. Estou em comunicação com Terminus. Ou melhor, posso me comunicar quando quiser, e Terminus pode, reversamente, se comunicar conosco. Eles sabem a localização da nave, pois observaram sua trajetória. Mesmo se não tivessem observado, poderiam nos localizar ao escanear o espaço próximo à procura de massas, o que os alertaria sobre a presença de uma nave ou, talvez, de um meteoroide.</p>
</blockquote>

<p>O diálogo avança mais um pouco:</p>

<blockquote>
  <p>[…]</p>

  <p>— Me parece, Golan — afirmou Pelorat —, que o <strong>avanço da civilização não é nada além de um exercício na limitação da privacidade.</strong></p>
</blockquote>

<p>O diálogo avança mais um pouco e em seguida, uma conclusão inusitada:</p>

<blockquote>
  <p>[…]</p>

  <p>Não haveria nenhum lugar na Galáxia onde poderíamos nos esconder e nenhuma combinação de Saltos pelo hiperespaço possibilitaria nossa evasão dos instrumentos à disposição deles.</p>

  <p>— Mas, Golan — disse Pelorat suavemente —, nós queremos a proteção da Fundação, não?</p>

  <p>— Sim, Janov, mas apenas quando a requisitarmos. Você disse que o avanço da civilização significava a contínua restrição de nossa privacidade. Bom, não quero algo tão avançado assim. <strong>Quero ter a liberdade de me locomover como bem entender, sem ser detectado, a não ser que eu deseje proteção ou precise dela.</strong> Portanto, eu me sentiria melhor, muito melhor, se não houvesse um hipertransmissor a bordo.</p>
</blockquote>]]></content><author><name>Fernando Bass</name></author><summary type="html"><![CDATA[Estou relendo o livro "Limites da Fundação" , de Isaac Asimov, e me deparei com alguns trechos interessantes que eu não lembrava desde a primeira vez que li,...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Open Source AI</title><link href="https://ferbass.xyz/2025/07/13/opensource-ai.html" rel="alternate" type="text/html" title="Open Source AI" /><published>2025-07-13T19:55:00+09:00</published><updated>2025-07-13T19:55:00+09:00</updated><id>https://ferbass.xyz/2025/07/13/opensource-ai</id><content type="html" xml:base="https://ferbass.xyz/2025/07/13/opensource-ai.html"><![CDATA[<p>I’m pretty sure you already heard or even use Open Source AI models.
In fact, as of today, there are more than 1 million models hosted on <a href="https://huggingface.co">HuggingFace</a>.
But before talking about Open Source AI, I want to talk about Open Source Software.</p>

<p>Open Source Software has been around for over three decades, and by now, we’re all familiar with what it means in terms of licensing,
access, and freedom. Open Source Software is defined by a few key principles: transparency, collaboration,
community-driven development, and shared ownership.
These characteristics ensure that users can inspect,
modify, and distribute the code freely, fostering innovation, reducing costs, and promoting trust in software.</p>

<p>It is important to remember that there are Organizations behind Open Source, these organizations ensure that Open Source Software remains ethical,
secure, and sustainable, while helping users navigate the complexities of licensing, attribution, and contribution guidelines.</p>

<p>So, how about AI? Like I mentioned before, if you access HuggingFace you will see
millions of models, but are those models Open Source? What does define an AI model to be Open Source?</p>

<p>An Open Source AI model is defined by principles similar to those of Open Source Software,
but with additional considerations specific to AI, such as:</p>

<ul>
  <li>Training methodology: This includes the algorithms, hyperparameters,
frameworks, and optimization techniques used during training.</li>
  <li>Training data provenance: This covers the sources, licenses, curation processes,
and ethical considerations (bias, fairness, and data privacy) of the training data.</li>
  <li>Documentation: Comprehensive documentation should accompany the model, including access to source code,
pre-trained weights, and step-by-step instructions for reproducibility.</li>
  <li>Ethical and legal compliance: The model must adhere to Open Source licenses like MIT, Apache,
and address ethical concerns such as bias audits, data privacy, and transparency in model behavior.</li>
  <li>Community and governance: Clear governance structures, maintainer roles,
and collaboration processes (contribution guidelines, version control)
should be established to ensure ongoing development and accountability.</li>
</ul>

<p>In Open Source AI, the source code must be freely accessible and distributed under permissive Open Source Licenses such as MIT,
Apache, or similar. Also, these models require full disclosure of the training methodology,
the origins and composition of the training data, and ethical considerations.</p>

<p>This is not just my own wish list. In October 2024 the Open Source Initiative published the
<a href="https://opensource.org/ai/open-source-ai-definition">Open Source AI Definition (OSAID)</a>,
the first official attempt to say what “Open Source” actually means for an AI model. One detail
worth calling out: OSAID does not demand that you release the entire training dataset. It asks
for enough information about the data, its sources, and how it was processed that someone could
recreate a similar model. That single choice is what makes the messy reality below possible.</p>

<p>Sounds great right? Well, despite these definitions and requirements, the reality of Open Source AI today is different.
Many models do share access to pre-trained weights, while full source code is often not available.
Training data is rarely disclosed, even when models are Open Source. This could be due to a combination of licensing constraints, legal risks (data privacy laws like GDPR),
and ethical concerns like protecting sensitive or personal information in the data. Also, these models require Petabytes of data,
and as you can imagine, sharing such massive datasets is not practical.
One more thing to consider is that training these models isn’t simple.
We are talking about tons of GPUs, which are super expensive, hard to come by, and take forever to run.
For big companies, this is less of a problem, they have the budget and the infrastructure. But for smaller teams, universities,
or Open Source collaborations, this could be challenging.</p>

<p>So, while the idea of Open Source AI sounds great, the reality is that people are stuck balancing transparency with the messy,
real world stuff: legal risks, data ethics, and size of the datasets.
It’s tricky, but it’s also a chance to rethink how we do AI responsibly without sacrificing progress.</p>

<p>Open Source AI isn’t dead, and it’s definitely not bad.
In fact, it’s out there, and it’s doing some seriously cool stuff.
You can download models, run them on your local machine or a dedicated server,
fine-tune them, or even use them with your own private data (like with RAG).
It’s all about customization, and that’s where the magic happens.</p>

<p>Projects like Open WebUI, Hugging Face, Ollama, and others are making this possible.
They’re acting like the ‘bridge’ between the big, fancy AI models (that require mountains of GPUs and cash) and the rest of us.
These tools let you pick and choose models that actually fit your needs whether you’re a student, a small team, or a nonprofit.</p>

<p>Check my previous post about <a href="https://ferbass.xyz/2025/06/13/running-your-local-llm-on-macos-using-docker-and-ollama.html">Running your local LLM on MacOS using Docker and Ollama</a>.</p>

<p>If you want to know more about Open Source and Open Source AI:</p>

<ul>
  <li><a href="https://lfaidata.foundation">Linux Foundation – AI &amp; Data Foundation</a></li>
  <li><a href="https://www.osaif.org">Open Source AI Foundation (OSAIF)</a></li>
  <li><a href="https://huggingface.co">Hugging Face</a></li>
  <li><a href="https://opensource.org">Open Source Initiative (OSI)</a></li>
</ul>

<p>That’s all for this post, I’m planning to keep exploring posts related to AI, so stay tuned.</p>]]></content><author><name>Fernando Bass</name></author><category term="LLM" /><category term="AI" /><category term="Open Source" /><category term="Ollama" /><category term="HuggingFace" /><summary type="html"><![CDATA[I'm pretty sure you already heard or even use Open Source AI models. In fact, as of today, there are more than 1 million models hosted on HuggingFace. But...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Running your local LLM on MacOS using Docker and Ollama</title><link href="https://ferbass.xyz/2025/06/13/running-your-local-llm-on-macos-using-docker-and-ollama.html" rel="alternate" type="text/html" title="Running your local LLM on MacOS using Docker and Ollama" /><published>2025-06-13T00:09:00+09:00</published><updated>2025-06-13T00:09:00+09:00</updated><id>https://ferbass.xyz/2025/06/13/running-your-local-llm-on-macos-using-docker-and-ollama</id><content type="html" xml:base="https://ferbass.xyz/2025/06/13/running-your-local-llm-on-macos-using-docker-and-ollama.html"><![CDATA[<p>In this post, we will explore how to run a local Large Language Model (LLM) on MacOS using Docker and Ollama. This setup allows you to leverage the power of LLMs without needing extensive hardware resources.</p>

<p>First, why run an LLM locally?</p>

<ul>
  <li><strong>Privacy</strong>: Your data remains on your machine, reducing the risk of data leaks.</li>
  <li><strong>Cost</strong>: Avoiding cloud costs associated with API calls.</li>
  <li><strong>Speed</strong>: Local inference can be faster than making API call (small-scale).</li>
</ul>

<h2 id="prerequisites">Prerequisites</h2>

<p>Everything I’m showing here was tested on MacOS 15.x with a M4 Pro cpu, but it should work on other versions as well.</p>

<ul>
  <li>
    <p><strong>Brew</strong> : Ensure you have Homebrew installed. If not, you can install it by running the following command in your terminal:</p>

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/bin/bash <span class="nt">-c</span> <span class="s2">"</span><span class="si">$(</span>curl <span class="nt">-fsSL</span> https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh<span class="si">)</span><span class="s2">"</span>
</code></pre></div>    </div>
    <ul>
      <li>access <a href="https://brew.sh">https://brew.sh</a> for more information.</li>
    </ul>
  </li>
  <li>
    <p><strong>Docker</strong>: Ensure you have Docker installed and running on your Mac. You can download it from <a href="https://www.docker.com/products/docker-desktop/">Docker’s official site</a>.</p>
    <ul>
      <li><strong>Docker-compose</strong></li>
    </ul>
  </li>
</ul>

<h2 id="get-started">Get started</h2>

<p>First of all, by the time I’m writing this, Docker for MacOS does not support GPU acceleration, and because I want to leverage on the GPU capabilities of my M4 chip, I’ll be running Ollama server outside of the Docker container and use Docker only for OpenWebUI, which is a web-based interface for interacting with LLMs.</p>

<h3 id="install-ollama">Install Ollama</h3>

<p>What is Ollama?
You can think about Ollama as a package manager for LLms, which allows you to run and manage LLMs locally. It provides a simple command-line interface to download, run, and interact with various LLMs.</p>
<ul>
  <li>Check <a href="https://ollama.com">Ollama’s website</a> for more information.</li>
</ul>

<p>To install Ollama, you can use Homebrew. Open your terminal and run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>ollama
</code></pre></div></div>

<p>Once installed, you can verify the installation by running:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama <span class="nt">--version</span>
</code></pre></div></div>

<p>This should display the version of Ollama you have installed.</p>

<p>To verify if Ollama is running you can check the status of the service:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew services info ollama
</code></pre></div></div>

<p>You should see that the service is running like the output below:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama (homebrew.mxcl.ollama)
Running: ✔
Loaded: ✔
Schedulable: ✘
User: ferbass
PID: 75692
</code></pre></div></div>

<p>if the service is not running you can start it with:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew services start ollama
</code></pre></div></div>

<p>Ok, now that you have Ollama installed and running we can download our first model to test.
For this example I’ll be using <a href="https://ollama.com/library/qwen3">qwen3:4b</a> which is a 4 billion parameter model that should run smoothly on M4 chips.</p>

<p>To download the model, run the following command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama run qwen3:4b
</code></pre></div></div>

<p>This command will pull and run the Qwen3 model right after downloading it.
Alternatively, you can use the <code class="language-plaintext highlighter-rouge">ollama pull</code> command to download the model without running it immediately.</p>

<p>After download the model and run it, you should see an input interface on your terminal to interact with the model.
Go ahead and try a few prompts to see how it responds. For example, you can ask it:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>❯ ollama run qwen3:4b
&gt;&gt;&gt; What is the capital of France?
Thinking...
Okay, the user is asking for the capital of France. Let me start by recalling what I know. France is a country in Europe, and I remember that its capital is Paris. But wait, I should
make sure I'm not confusing it with another city. Let me think... Paris is definitely the capital. I think it's also the largest city in France. But maybe I should double-check.
Sometimes people might confuse other major cities like Lyon or Marseille, but those are not the capitals. The capital is Paris, right? Yes, that's correct. So the answer is Paris. I
should present that clearly.
...done thinking.

The capital of France is **Paris**.

&gt;&gt;&gt;
</code></pre></div></div>

<p>Cool, now that we have Ollama running and a model downloaded, we can move on to the next step: setting up OpenWebUI.</p>

<h3 id="install-openwebui">Install OpenWebUI</h3>

<p>OpenWebUI is a self-hosted, open-source chat interface for LLMs. It gives you a web-based experience similar to ChatGPT — but fully local, private, and customizable. It’s designed to work out-of-the-box with backends like Ollama, LM Studio, or OpenAI-compatible APIs, making it perfect for macOS setups running LLMs natively.
I’ll not go into details about OpenWebUI in this post, but you can check their <a href="https://github.com/open-webui/open-webui">GitHub repository</a> for more information.</p>

<p>To run OpenWebUI, we will use Docker. First, ensure you have Docker installed and running on your Mac.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">services</span><span class="pi">:</span>
  <span class="na">open-webui</span><span class="pi">:</span>
    <span class="na">image</span><span class="pi">:</span> <span class="s">ghcr.io/open-webui/open-webui:main</span>
    <span class="na">container_name</span><span class="pi">:</span> <span class="s">open-webui</span>
    <span class="na">ports</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s2">"</span><span class="s">3000:8080"</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">OLLAMA_BASE_URL=http://host.docker.internal:11434</span>
    <span class="na">volumes</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="s">${HOME}/open-webui-data:/app/backend/data</span>
    <span class="na">restart</span><span class="pi">:</span> <span class="s">unless-stopped</span>

<span class="na">volumes</span><span class="pi">:</span>
  <span class="na">open-webui-data</span><span class="pi">:</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker-compose up <span class="nt">-d</span>
</code></pre></div></div>

<p>This command will start the OpenWebUI service in detached mode.</p>

<p>If everything goes well, you should see be able to access http://localhost:3000 and interact with OpenWebAI via your web browser using the model you downloaded earlier.</p>

<h3 id="what-is-next">what is next</h3>

<p>Now that you have a local LLM running using Ollama and OpenWebUI,
I encorage you to explore more models and settings and try to see which one works best for you.
You can find more models on the <a href="https://ollama.com/search">Ollama</a>.</p>

<p>So far on my tests using a MacBook M4 Pro with 48GB of RAM, I was able to run models size up to 8B parameters without any issue.</p>

<p>I encorage you explore different models, settings, and try to play around with Knowledge Base, Tools and Function which I will explore in future posts.</p>

<p>Thanks</p>]]></content><author><name>Fernando Bass</name></author><category term="docker" /><category term="ai" /><category term="ollama" /><category term="llm" /><summary type="html"><![CDATA[In this post, we will explore how to run a local Large Language Model (LLM) on MacOS using Docker and Ollama. This setup allows you to leverage the power of...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Inspiration</title><link href="https://ferbass.xyz/2025/04/17/inspiration.html" rel="alternate" type="text/html" title="Inspiration" /><published>2025-04-17T14:01:00+09:00</published><updated>2025-04-17T14:01:00+09:00</updated><id>https://ferbass.xyz/2025/04/17/inspiration</id><content type="html" xml:base="https://ferbass.xyz/2025/04/17/inspiration.html"><![CDATA[<p>This is not a technical post, but I want to share that here.
I’m again attending RubyKaigi, which is held this year in Matsuyama, Ehime, Japan.
I have been to many conferences, but RubyKaigi is always special. Talking with Matz, committers, and other Rubyists is always a great experience. There are many Rubyists I only meet once a year during RubyKaigi.
Although I haven’t been writing Ruby code professionally for quite a while, I still love the language and the community, which is why I always attend RubyKaigi.
I have been here at RubyKaigi, and it inspires me to write better code and be a better person.
Many people here share the same feeling about the Ruby community and the Ruby language, and many get inspired to make something great after being here.</p>

<p>That is all. I just wanted to share my feelings and how inspiring RubyKaigi is for me and many other Rubyists</p>

<p>One more thing: I would like to share a book by my friend Guilherme Orlandini Heurich, an Anthropologist who has studied the Ruby community for the past few years and has released the book called <a href="https://discovery.ucl.ac.uk/id/eprint/10188850/1/Coderspeak.pdf">CODESPEAK</a>.</p>

<p>Thank you</p>

<p>#rubykaigi #rubykaigi2025 #inspiration</p>]]></content><author><name>Fernando Bass</name></author><category term="ruby" /><category term="inspiration" /><category term="code" /><category term="rubykaigi2025" /><summary type="html"><![CDATA[This is not a technical post, but I want to share that here. I'm again attending RubyKaigi, which is held this year in Matsuyama, Ehime, Japan. I have been to...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Changing GPG Password</title><link href="https://ferbass.xyz/2025/01/21/changing-gpg-passphrase.html" rel="alternate" type="text/html" title="Changing GPG Password" /><published>2025-01-21T13:31:00+09:00</published><updated>2025-01-21T13:31:00+09:00</updated><id>https://ferbass.xyz/2025/01/21/changing-gpg-passphrase</id><content type="html" xml:base="https://ferbass.xyz/2025/01/21/changing-gpg-passphrase.html"><![CDATA[<p>Sometime ago I posted about <a href="/2021/05/05/gpg-key-generate-setting-up-backup-and-restore.html">GPG Key</a> explaining how to generate, setup, backup and restore it.
Now I would like to add something to it, recently I decided to change my key password and now I’m taking notes of the proccess in this post.</p>

<p>First find the key you want to change the password</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gpg <span class="nt">--list-keys</span>
</code></pre></div></div>

<p>I particularly like to use <code class="language-plaintext highlighter-rouge">--list-keys</code> with <code class="language-plaintext highlighter-rouge">--keyid-format LONG</code> parameter, to get the long key id, which is easier to work with.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gpg <span class="nt">--list-secret-keys</span> <span class="nt">--keyid-format</span> LONG
<span class="o">[</span>keyboxd]
<span class="nt">---------</span>
sec   rsa4096/87C6D385122B1669 2017-12-04 <span class="o">[</span>SC]
      7D30FFF3FC256153A59AEA698726DF85922B8669
uid                 <span class="o">[</span>ultimate] Fernando Bass <span class="o">(</span>no comments<span class="o">)</span> &lt;xyz@mail.com&gt;
ssb   rsa4096/7A822B866BD5E7D1 2017-12-04 <span class="o">[</span>E]
</code></pre></div></div>

<p>In this case, the key id is <code class="language-plaintext highlighter-rouge">87C6D385122B1669</code>.</p>

<p>Now lets change the password</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>gpg <span class="nt">--edit-key</span> 87C6D385122B1669
</code></pre></div></div>

<p>This will open the <code class="language-plaintext highlighter-rouge">gpg</code> console, where you can change the password</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg&gt; passwd
</code></pre></div></div>

<p>If your key is protected, you will be asked to enter the current password,
after that a prompt asking to type the new password and confirmation will be displayed.</p>

<p>If everything goes well, you should be redirect to the <code class="language-plaintext highlighter-rouge">gpg</code> console, where you can save the changes.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gpg&gt; save
</code></pre></div></div>

<p>Thats it, after save your changes you can start using your GPG Key with the new password.</p>

<p>Thank you</p>]]></content><author><name>Fernando Bass</name></author><category term="gpg" /><category term="security" /><category term="private keys" /><category term="public keys" /><category term="password" /><summary type="html"><![CDATA[Sometime ago I posted about GPG Key explaining how to generate, setup, backup and restore it. Now I would like to add something to it, recently I decided to...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Management (From a Software Engineer’s POV)</title><link href="https://ferbass.xyz/2024/12/11/management-from-an-software-engineer-pov.html" rel="alternate" type="text/html" title="Management (From a Software Engineer’s POV)" /><published>2024-12-11T00:00:00+09:00</published><updated>2024-12-11T00:00:00+09:00</updated><id>https://ferbass.xyz/2024/12/11/management-from-an-software-engineer-pov</id><content type="html" xml:base="https://ferbass.xyz/2024/12/11/management-from-an-software-engineer-pov.html"><![CDATA[<p>This is an R-rated post. I am going to be very direct and honest, based on
my personal experience and how I see things. It is not a general rule. If
you do not agree with anything you read here, that is fine. I am not here
to define rules, and I am happy to hear your thoughts.</p>

<h2 id="the-part-nobody-warns-you-about">The part nobody warns you about</h2>

<p>Here is the thing nobody tells you when you move from engineering into
management. The day you take the job, you stop shipping code, and your
output stops being your output. It becomes whatever your team ships. That
sounds obvious until you live it. Your instinct is still to open the editor
and fix the bug yourself, because that is what you are good at and it feels
productive. But every hour you spend doing your old job is an hour you are
not doing the new one.</p>

<p>The hardest part is technical credibility. You earned your spot because you
were a good engineer, and you can still read the pull request and see the
better way to do it. The question is whether you should. Jumping in and
rewriting someone’s code proves you are smart and teaches them nothing.
Most of the time the right move is to ask a question and let them get there
themselves, even if it is slower, even if it is not how you would have done
it. Letting go of the keyboard is most of the job, and it is much harder
than it sounds.</p>

<p>And yes, the question every engineer asks: do I keep coding? My answer is a
little, on things that are not on the critical path, so you do not lose
touch with the tools your team lives in. But the moment your coding becomes
a blocker for someone else, you drop it. Your job is now to make the team
fast, not to be the fastest person on it.</p>

<p>First of all, if you are a manager or in a management position, you have to
remember that delivering the project is the goal, but to get there you have
to manage people. And people are not robots. They have feelings, they have
personal problems, they have their own goals, and you have to understand
that. You can read tons of books on how to manage teams or projects, but
dealing with people is not something you learn from a book. You learn it
through the relationships you build over time.</p>

<p>A successful manager is the one who can listen to their team, understand
their needs, and help them achieve their goals. Not the one who is there
only to create Gantt charts and burndowns and keep asking for daily
reports. A true manager trusts their team. If you do not trust your team,
you have to reconsider something. And most important of all, a manager who
has the team on their side can achieve the impossible.</p>

<h2 id="what-trusting-your-team-actually-looks-like">What trusting your team actually looks like</h2>

<p>Trust is easy to say and hard to do. In practice it means handing people a
problem instead of a task, and letting them own the solution. It means
removing the blockers in their way and then getting out of the way. It
means a one on one that is about them, their growth and what is frustrating
them, not a status update you could have read in the ticket. And it means
taking the hit publicly when something goes wrong, and giving them the
credit publicly when it goes right.</p>

<p>Do those things and you will not need to ask for daily reports. You already
know how your team is doing, because you actually talk to them.</p>

<p>To finish, if you are a manager or want to become one, be ready to make
decisions when they are needed, even when you do not have all the
information you would like. Someone who cannot make a decision and take
responsibility for it should never become a manager.</p>

<p>A manager’s job is not to receive orders, ask for status updates, and keep
a spreadsheet up to date. If your job is all about checking the backlog and
creating reports, then I am sorry, but you are not a manager. You are a
bureaucrat.</p>

<h2 id="further-reading">Further reading</h2>

<p>This post is my opinion, not a manual. If you want to go deeper and learn
from people who have written proper books on the subject, these are the
ones worth your time:</p>

<ul>
  <li><strong>James Stanier, <em>Become an Effective Software Engineering Manager</em></strong> — the
most direct answer to “I am an engineer, now what do I do as a manager?”.</li>
  <li><strong>Camille Fournier, <em>The Manager’s Path</em></strong> — the path from tech lead to
senior leadership, stage by stage.</li>
  <li><strong>Will Larson, <em>An Elegant Puzzle: Systems of Engineering Management</em></strong> — for
the systems and structure side of the job.</li>
  <li><strong>Andrew S. Grove, <em>High Output Management</em></strong> — the old classic, still the
best on leverage and output.</li>
  <li><strong>Kim Scott, <em>Radical Candor</em></strong> — on caring about people while still being
direct with them, which is most of what I wrote about above.</li>
  <li><strong>Will Larson, <em>Staff Engineer: Leadership Beyond the Management Track</em>
(2021)</strong> — the reminder that management is not the only way up. You can
lead as a senior engineer without ever managing people.</li>
</ul>

<hr />

<p><em>This post was written with the help of AI (Claude by Anthropic).</em></p>]]></content><author><name>Fernando Bass</name></author><category term="management" /><category term="career" /><category term="management" /><category term="leadership" /><category term="career" /><category term="engineering" /><summary type="html"><![CDATA[This is an R-rated post. I am going to be very direct and honest, based on my personal experience and how I see things. It is not a general rule. If you do not...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://ferbass.xyz/img/og-default.png" /><media:content medium="image" url="https://ferbass.xyz/img/og-default.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>