<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/static/blog-feed.xsl?v=1778652482303890535"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>FeedFilters Blog</title>
  <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog"/>
  <link rel="self" type="application/atom+xml" href="https://test.feedfilters.net/blog/feed"/>
  <id>https://test.feedfilters.net/blog/</id>
  <updated>2026-05-12T12:00:00Z</updated>
  <author>
    <name>Flat Six Software</name>
  </author>
  <entry>
    <title>Where the cookie boundary didn&apos;t</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/12/where-the-cookie-boundary-didnt"/>
    <id>tag:test.feedfilters.net,2026-05-12:/blog/where-the-cookie-boundary-didnt</id>
    <published>2026-05-12T12:00:00Z</published>
    <updated>2026-05-12T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>A confident claim from the last post turned out to be wrong in three load-bearing ways. The fix and what I should have checked first.</summary>
    <content type="html">&lt;p&gt;&lt;a href=&quot;/blog/2026/05/11/a-cdn-for-most-of-it&quot;&gt;Yesterday&amp;rsquo;s post&lt;/a&gt; included this
sentence about how Cloudflare was supposed to keep authenticated HTML
out of the edge cache:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;FeedFilters&amp;rsquo; authenticated routes already carry &lt;code&gt;Set-Cookie&lt;/code&gt; —
gorilla/csrf sets one on every response it sees — so the
cookie boundary already does most of the work.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That sentence is wrong in three different ways, none of which I
noticed until prod was already serving cached pages across users.&lt;/p&gt;
&lt;h2 id=&quot;the-symptom&quot;&gt;The symptom&lt;/h2&gt;
&lt;p&gt;I was editing a feed in the app — flipping the filter mode,
adding a tag — and the changes weren&amp;rsquo;t showing up in the list.
The CDN was the most recent thing I&amp;rsquo;d changed, so I curled the page
with my session cookies and looked at the response headers.
&lt;code&gt;cf-cache-status: HIT&lt;/code&gt;, non-zero &lt;code&gt;age:&lt;/code&gt;. Cloudflare had cached the
page and was handing the stale copy back to my browser.&lt;/p&gt;
&lt;p&gt;Worse: the cached page contained one user&amp;rsquo;s feed list. Anyone else
who hit &lt;code&gt;/&lt;/code&gt; while that entry was still alive at the edge could see
it instead of their own. I don&amp;rsquo;t have evidence anyone actually got
someone else&amp;rsquo;s data, but the conditions for it to happen were there.&lt;/p&gt;
&lt;h2 id=&quot;three-things-i-had-wrong&quot;&gt;Three things I had wrong&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;gorilla/csrf does not set a cookie on every response.&lt;/strong&gt; It only
writes &lt;code&gt;Set-Cookie&lt;/code&gt; when generating a new token — when the
existing &lt;code&gt;_gorilla_csrf&lt;/code&gt; cookie is missing or fails HMAC. Once a
visitor has a valid one, every subsequent response leaves origin
with no &lt;code&gt;Set-Cookie&lt;/code&gt; at all. Most authenticated traffic falls on
the no-&lt;code&gt;Set-Cookie&lt;/code&gt; side of that.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cloudflare&amp;rsquo;s cache rule overrode the default &lt;code&gt;Set-Cookie&lt;/code&gt;
bypass.&lt;/strong&gt; Cloudflare normally refuses to cache responses with
&lt;code&gt;Set-Cookie&lt;/code&gt;. The cache rule I wrote yesterday sets &lt;code&gt;cache = true&lt;/code&gt;
explicitly, which is the documented way to force-cache responses
the default would skip. So even on the first hit, when there &lt;em&gt;was&lt;/em&gt;
a &lt;code&gt;Set-Cookie&lt;/code&gt;, the rule was force-caching it anyway.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cloudflare&amp;rsquo;s cache key doesn&amp;rsquo;t honor &lt;code&gt;Vary: Cookie&lt;/code&gt;.&lt;/strong&gt; gorilla/csrf
adds &lt;code&gt;Vary: Cookie&lt;/code&gt; to every response in its group, which is the
spec-correct way to tell shared caches that a response varies per
cookie. Cloudflare&amp;rsquo;s default cache key is &lt;code&gt;scheme + host + path + query&lt;/code&gt;, and it does not extend that with &lt;code&gt;Vary: Cookie&lt;/code&gt;. The header
passes through to the browser but, from Cloudflare&amp;rsquo;s perspective,
is decoration.&lt;/p&gt;
&lt;p&gt;Each individual piece is documented if you go looking. The
composition is what got me — three independent assumptions,
each one of which would have been enough on its own, and all three
of which turned out to be the opposite of what I&amp;rsquo;d assumed.&lt;/p&gt;
&lt;h2 id=&quot;the-fix&quot;&gt;The fix&lt;/h2&gt;
&lt;p&gt;Authenticated routes default to &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt;
via a small middleware that runs first in the CSRF group. That&amp;rsquo;s
the explicit &lt;em&gt;&amp;ldquo;don&amp;rsquo;t cache&amp;rdquo;&lt;/em&gt; signal the gorilla/csrf cookie was
supposed to be and isn&amp;rsquo;t. Cloudflare&amp;rsquo;s &lt;code&gt;respect_origin&lt;/code&gt; mode
honours &lt;code&gt;no-store&lt;/code&gt;, so the edge stops holding onto authenticated
HTML regardless of what other headers the response carries.&lt;/p&gt;
&lt;p&gt;The anonymous lander came out of the CSRF group entirely. Yesterday&amp;rsquo;s
plan kept authenticated routes and the cacheable lander in the same
middleware chain on the assumption that the cookie boundary would
separate them; once the cookie boundary turned out not to exist,
the lander needed its own home. &lt;code&gt;/&lt;/code&gt; now lives outside the CSRF
group, with &lt;code&gt;handleRoot&lt;/code&gt; either rendering the lander for anonymous
visitors or 303-redirecting signed-in users to a new &lt;code&gt;/feeds&lt;/code&gt;
route inside the auth group. The anonymous response goes out with
no &lt;code&gt;Set-Cookie&lt;/code&gt; and no &lt;code&gt;Vary: Cookie&lt;/code&gt;, and Cloudflare actually
caches it.&lt;/p&gt;
&lt;p&gt;The Cloudflare cache rule grew a session-cookie guard so it only
matches requests without a &lt;code&gt;session=&lt;/code&gt; cookie. Belt and suspenders
with the app&amp;rsquo;s &lt;code&gt;no-store&lt;/code&gt; default: the rule&amp;rsquo;s &lt;code&gt;cache = true&lt;/code&gt;
force-caching no longer applies to anything authenticated,
regardless of what the origin happens to send.&lt;/p&gt;
&lt;p&gt;A regression test in &lt;code&gt;cache_control_test.go&lt;/code&gt; asserts the lander
response has no &lt;code&gt;Set-Cookie&lt;/code&gt; and no &lt;code&gt;Vary: Cookie&lt;/code&gt;, so the
boundary is something written down rather than emergent.&lt;/p&gt;
&lt;h2 id=&quot;what-id-take-from-this&quot;&gt;What I&amp;rsquo;d take from this&lt;/h2&gt;
&lt;p&gt;Putting a CDN in front of a dynamic application isn&amp;rsquo;t a one-line
config decision. It&amp;rsquo;s a contract between three independent moving
parts — the framework&amp;rsquo;s response-header behaviour, the CDN&amp;rsquo;s
caching rules, and the actual content boundary in your routes
— and getting it wrong has correctness implications, not
just performance ones. It deserves full attention and a verification
pass through the edge, not a confident-sounding sentence in a
deploy summary.&lt;/p&gt;
&lt;p&gt;The concrete change for me is small: any caching-adjacent edit
now gets a &lt;code&gt;curl&lt;/code&gt; through the CDN before I call it done. Looking
at &lt;code&gt;cf-cache-status&lt;/code&gt; on a few representative paths takes about a
minute, and it would have caught this the day it shipped.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>A CDN for most of it</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/11/a-cdn-for-most-of-it"/>
    <id>tag:test.feedfilters.net,2026-05-11:/blog/a-cdn-for-most-of-it</id>
    <published>2026-05-11T12:00:00Z</published>
    <updated>2026-05-11T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>What Cloudflare&apos;s edge cache actually buys you, where it makes sense to apply that to FeedFilters, and how the answer turned out to be most of the site rather than just the feeds.</summary>
    <content type="html">&lt;p&gt;A CDN like Cloudflare sits in front of an origin server, caches
responses at edge nodes around the world, and serves them to
clients from whichever edge is closest. That&amp;rsquo;s the headline. The
bundle wrapped around that headline is what makes the actual
decision interesting.&lt;/p&gt;
&lt;p&gt;The full set of things you get from turning Cloudflare on for a
hostname looks more like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cache hits served from the edge, when the response is
cacheable and the cache has a fresh copy.&lt;/li&gt;
&lt;li&gt;TLS terminated at the edge, close to the client, regardless of
whether the response is cacheable.&lt;/li&gt;
&lt;li&gt;DDoS shielding and bot/abuse filtering applied before any
request reaches origin.&lt;/li&gt;
&lt;li&gt;The origin IP hidden behind Cloudflare&amp;rsquo;s anycast network.&lt;/li&gt;
&lt;li&gt;Free, with no contract.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the costs, in roughly the same shape:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An opaque hop in the middle of every request path. When
something misbehaves, &lt;em&gt;&amp;ldquo;is it me or the CDN&amp;rdquo;&lt;/em&gt; is a new question
to triage every time.&lt;/li&gt;
&lt;li&gt;Some zone-level settings — SSL mode, security policies
— apply across every proxied hostname, so a tuning
change for one site can affect another by accident.&lt;/li&gt;
&lt;li&gt;Headers you didn&amp;rsquo;t have before show up (&lt;code&gt;CF-Connecting-IP&lt;/code&gt;,
&lt;code&gt;CF-Ray&lt;/code&gt;), and code that expected &lt;code&gt;r.RemoteAddr&lt;/code&gt; to be the
client IP starts seeing a Cloudflare edge IP instead.&lt;/li&gt;
&lt;li&gt;Authenticated or per-user responses don&amp;rsquo;t cache safely. If
they do end up cached, you&amp;rsquo;ve leaked one user&amp;rsquo;s view to
another.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The cache itself, which is what most people mean when they say
&lt;em&gt;&amp;ldquo;a CDN,&amp;rdquo;&lt;/em&gt; is only one of those line items. It helps a lot when
you serve identical responses to many readers and not at all
when each reader gets a unique answer. The rest of the bundle
— the edge TLS, the shielding, the buffered absorption of
abuse — applies regardless of whether the cache ever gets
a hit.&lt;/p&gt;
&lt;h2 id=&quot;where-it-fits-for-feedfilters&quot;&gt;Where it fits for FeedFilters&lt;/h2&gt;
&lt;p&gt;The first cut at this looked like a clean split between the two
public hostnames. The &lt;strong&gt;feeds host&lt;/strong&gt; serves anonymous,
machine-driven traffic: every feed URL is an unguessable ID
that a subscriber configures into their RSS reader once, and
from that point on the reader polls on its own schedule
— typically every fifteen minutes to an hour. The
response is the same for every reader who hits the URL inside
the cache window, which is exactly the shape an edge cache is
good at. The &lt;strong&gt;web app&lt;/strong&gt; at the apex looked like the opposite:
per-user pages rendered against a session cookie, no two
responses alike, nothing to cache safely.&lt;/p&gt;
&lt;p&gt;Drawing the line at the hostname is tempting because it maps so
cleanly to the DNS layer — orange-cloud the feeds host,
leave the apex grey. But it elides something. The web app
serves more than authenticated pages. The blog, the about page,
the privacy and terms pages, the anonymous landing on &lt;code&gt;/&lt;/code&gt;
— those don&amp;rsquo;t depend on who&amp;rsquo;s asking. The same response
would go to every visitor, and there&amp;rsquo;s no reason an edge can&amp;rsquo;t
hand it back without checking with origin every time. The real
split inside the web app isn&amp;rsquo;t between hostnames; it&amp;rsquo;s between
&lt;em&gt;anonymous&lt;/em&gt; and &lt;em&gt;authenticated&lt;/em&gt; responses, and the boundary
runs through the router rather than through the DNS.&lt;/p&gt;
&lt;p&gt;Once that&amp;rsquo;s the line, Cloudflare&amp;rsquo;s proxy in front of the apex
doesn&amp;rsquo;t need to be avoided. The standard mechanism a cache uses
to tell anonymous responses from per-user ones is the response
header: anything carrying a &lt;code&gt;Set-Cookie&lt;/code&gt; is treated as per-user
and skipped, and anything with &lt;code&gt;Cache-Control: public&lt;/code&gt; is
considered eligible. FeedFilters&amp;rsquo; authenticated routes already
carry &lt;code&gt;Set-Cookie&lt;/code&gt; — gorilla/csrf sets one on every
response it sees — so the cookie boundary already does
most of the work. The remaining gap was a server-side change:
stop running gorilla/csrf on the routes that don&amp;rsquo;t need it.&lt;/p&gt;
&lt;p&gt;So: most of FeedFilters goes orange. The apex, &lt;code&gt;www&lt;/code&gt;, and the
feeds host are all proxied; the cookie boundary inside the app
decides what the edge caches and what it doesn&amp;rsquo;t. The only
things that stay grey are operational paths that don&amp;rsquo;t speak
HTTP at all — inbound mail on port 25, and SSH on port 22
for the deploy workflow. Those moved to their own grey-clouded
hostnames (&lt;code&gt;mail.feedfilters.net&lt;/code&gt;, &lt;code&gt;host.feedfilters.net&lt;/code&gt;) so
the SMTP and SSH listeners on the box keep working through DNS
that bypasses Cloudflare entirely.&lt;/p&gt;
&lt;h2 id=&quot;in-code-not-in-the-dashboard&quot;&gt;In code, not in the dashboard&lt;/h2&gt;
&lt;p&gt;By the time the &lt;a href=&quot;/blog/2026/05/05/from-scripts-to-infrastructure&quot;&gt;deployment work&lt;/a&gt;
shipped a few days ago, nothing about the live system&amp;rsquo;s state
existed only in a Cloudflare or Linode dashboard tab. The DNS
records, the reverse-PTR, the DKIM key, the mail records, the
box itself — all of it lived in OpenTofu state. Adding a
CDN to that picture only counted if it landed the same way: in
code, applied by the same &lt;code&gt;tofu apply&lt;/code&gt;, swappable later without
anyone trying to remember which UI a setting had been clicked
in.&lt;/p&gt;
&lt;p&gt;The pieces this turned into, all in the same &lt;code&gt;tofu apply&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;DNS.&lt;/strong&gt; &lt;code&gt;feedfilters.net&lt;/code&gt;, &lt;code&gt;www.feedfilters.net&lt;/code&gt;, and
&lt;code&gt;feeds.feedfilters.net&lt;/code&gt; orange-clouded. &lt;code&gt;mail.feedfilters.net&lt;/code&gt;
and &lt;code&gt;host.feedfilters.net&lt;/code&gt; (the new MX target and the new SSH
target) grey-clouded. The MX record repointed from the apex
to &lt;code&gt;mail.feedfilters.net&lt;/code&gt;. SPF stayed correct because it uses
the &lt;code&gt;mx&lt;/code&gt; mechanism plus an explicit &lt;code&gt;ip4:&lt;/code&gt; literal, both of
which still resolve to the same origin IP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zone settings.&lt;/strong&gt; SSL mode to &lt;em&gt;Full (Strict)&lt;/em&gt; so Cloudflare
verifies the Let&amp;rsquo;s Encrypt certificate Caddy is already
serving; &lt;em&gt;Always Use HTTPS&lt;/em&gt; on so the edge handles the
http-to-https redirect rather than the origin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache rule.&lt;/strong&gt; Scoped to the three orange-clouded hostnames,
set to respect the origin&amp;rsquo;s &lt;code&gt;Cache-Control&lt;/code&gt; headers. Without
it, Cloudflare&amp;rsquo;s default behaviour caches only by file
extension, which doesn&amp;rsquo;t match either the extensionless
&lt;code&gt;/{id}&lt;/code&gt; feed URLs or the app&amp;rsquo;s HTML routes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caddy ACME pin.&lt;/strong&gt; A one-line label in FeedFilters&amp;rsquo; compose
telling Caddy to use the HTTP-01 ACME challenge rather than
TLS-ALPN-01. TLS-ALPN-01 can&amp;rsquo;t reach origin once Cloudflare
terminates TLS at the edge; HTTP-01 keeps working because
Cloudflare passes &lt;code&gt;/.well-known/acme-challenge/*&lt;/code&gt; straight
through to origin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;App-side routing.&lt;/strong&gt; The truly public routes (&lt;code&gt;/about&lt;/code&gt;,
&lt;code&gt;/help&lt;/code&gt;, &lt;code&gt;/privacy&lt;/code&gt;, all of &lt;code&gt;/blog/*&lt;/code&gt;) moved into a chi
router group that doesn&amp;rsquo;t include the gorilla/csrf middleware.
With no &lt;code&gt;Set-Cookie&lt;/code&gt; on the response, Cloudflare&amp;rsquo;s edge
actually caches it. A small &lt;code&gt;publicCacheMiddleware&lt;/code&gt; sets
&lt;code&gt;Cache-Control: public, max-age=300&lt;/code&gt; and clears the signed-in
user from the request context so the layout&amp;rsquo;s auth-aware
chrome doesn&amp;rsquo;t differ across visitors and break the cache.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There was one thing not in the plan. The GitHub Actions deploy
workflow used to scp/ssh into the apex hostname. The apex now
resolves to Cloudflare edges, and Cloudflare&amp;rsquo;s proxy doesn&amp;rsquo;t
forward port 22, so the first deploy after the cutover died
with &lt;code&gt;Network is unreachable&lt;/code&gt;. The fix was the grey-clouded
&lt;code&gt;host.feedfilters.net&lt;/code&gt; record above, a small extension of the
host-keys capture script to emit &lt;code&gt;DEPLOY_KNOWN_HOSTS&lt;/code&gt; entries
against the new name, and a one-line workflow change to scp/ssh
through the new hostname.&lt;/p&gt;
&lt;h2 id=&quot;what-i-noticed&quot;&gt;What I noticed&lt;/h2&gt;
&lt;p&gt;The thing I keep coming back to is how cleanly the cookie
boundary doubles as the cache boundary. Every response that
needs CSRF protection carries a &lt;code&gt;Set-Cookie&lt;/code&gt;, and &lt;code&gt;Set-Cookie&lt;/code&gt;
is the signal every well-behaved cache uses to mean &lt;em&gt;&amp;ldquo;this is
per-user, don&amp;rsquo;t store.&amp;rdquo;&lt;/em&gt; The work was less about adding caching
logic than about removing CSRF protection from routes that
didn&amp;rsquo;t need it in the first place — the about, help,
privacy, and blog pages. The right behaviour fell out almost
by accident.&lt;/p&gt;
&lt;p&gt;The other thing was TLS. Caddy is still doing what Caddy does
for every hostname that reaches it: serving a Let&amp;rsquo;s Encrypt
cert. For the orange-clouded hostnames that client is now
Cloudflare, doing its own TLS dance at the edge and validating
my cert on the back half. Two layers of TLS for those paths
isn&amp;rsquo;t strictly necessary — the alternative is to
terminate only at the edge and let Cloudflare talk to origin
over plain HTTP — but that would create a class of
failure where flipping the wrong zone setting silently
downgrades transport security on the wrong subdomain. That&amp;rsquo;s
not the trade I want, and the redundancy is cheap.&lt;/p&gt;
&lt;p&gt;Most of the time went on the deciding part, not the doing
part, which I&amp;rsquo;m taking as a good sign about where the
infrastructure is now.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Done, not abandoned</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/08/done-not-abandoned"/>
    <id>tag:test.feedfilters.net,2026-05-08:/blog/done-not-abandoned</id>
    <published>2026-05-08T12:00:00Z</published>
    <updated>2026-05-08T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>When software stops changing, it&apos;s easy to assume the maintainer wandered off. Sometimes they did. Sometimes the software is just done.</summary>
    <content type="html">&lt;p&gt;When I&amp;rsquo;m checking out a new app, one of the first things I look at
is the &lt;em&gt;last updated&lt;/em&gt; date in the App Store. A recent date is a
green flag. &lt;em&gt;Last updated 2 years ago&lt;/em&gt; is a yellow one. &lt;em&gt;Has the
developer wandered off? Is this thing dead?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That instinct isn&amp;rsquo;t crazy. Plenty of software does just stop
because the person making it loses interest or runs out of time,
and a stale &amp;ldquo;last updated&amp;rdquo; line is often the first sign. But it
isn&amp;rsquo;t the only reason a piece of software might go quiet.
Sometimes the developer hasn&amp;rsquo;t gone anywhere. They&amp;rsquo;ve just decided
the thing is done.&lt;/p&gt;
&lt;p&gt;I want to be careful here. I&amp;rsquo;m not arguing that &amp;ldquo;done&amp;rdquo; software is
better than software that keeps changing. There&amp;rsquo;s plenty of
software that genuinely needs to keep changing — operating
systems chasing security disclosures, browsers chasing the moving
target of the web, products with real competitors that have to
keep up. There are also products on growth treadmills for less
edifying reasons, but even then getting off the treadmill often
isn&amp;rsquo;t an option for the team running them. None of this is what
I want to push back on.&lt;/p&gt;
&lt;p&gt;What I want to push back on is the assumption that quiet equals
dead. The last twenty years of software have trained us, mostly
correctly, to read constant updates as a sign of vitality. The
flip side of that training is that anything &lt;em&gt;not&lt;/em&gt; getting constant
updates starts to feel like it&amp;rsquo;s failing some basic test of
liveness. &lt;em&gt;No new release in a year? Probably abandoned.&lt;/em&gt; That
shorthand catches a lot of true positives. It also catches
things that aren&amp;rsquo;t broken at all.&lt;/p&gt;
&lt;p&gt;The example I keep coming back to is games. There are plenty of
modern games on indefinite update cycles — live-service
games, online shooters, anything with a season pass — and
that&amp;rsquo;s fine, it suits what they&amp;rsquo;re doing. But there are also
classic games that shipped, got a handful of patches, and never
got updated again, and they&amp;rsquo;re just as fun to play now as they
were the year they came out. Tetris didn&amp;rsquo;t need a roadmap. The
absence of patches isn&amp;rsquo;t a defect; the game just works.&lt;/p&gt;
&lt;p&gt;Software that does a defined job can be like that too. Not all of
it — but more of it than the &lt;em&gt;last updated&lt;/em&gt; date suggests.
A static-site generator that takes
markdown and emits HTML isn&amp;rsquo;t going to need quarterly redesigns
to remain useful. A keyboard remapper that sits between a USB
device and the OS doesn&amp;rsquo;t need a roadmap. A web service that
filters RSS feeds will just quietly keep working. Software that&amp;rsquo;s
bounded by a finite problem can be finished in a real sense. It doesn&amp;rsquo;t
mean nothing ever changes — security patches happen, browser
quirks get worked around, dependencies get bumped — but it
does mean the software has stopped chasing.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s where I&amp;rsquo;m aiming with FeedFilters. The plan is to get it
feature-complete sometime this year, and then move on to other
projects, checking in every now and then to make sure things
still work. There are still a handful of refinements I want to
make. I&amp;rsquo;m also open to ideas from people who actually use it
— the core is small and I&amp;rsquo;m sure there are good suggestions
I haven&amp;rsquo;t thought of. But the whole concept only goes so far.
There&amp;rsquo;s a horizon to this project, and once the work is up to
that horizon, the work is done.&lt;/p&gt;
&lt;p&gt;Part of the reason I&amp;rsquo;m writing this post is to say so out loud,
ahead of time. If someone finds FeedFilters in 2028 and notices
that the last post on this blog was from a year ago, I&amp;rsquo;d like that
to read as the state I was aiming for, not as a sign that I bailed. I&amp;rsquo;ll still
be around if something breaks. Most of the time, though, nothing
should need to. That&amp;rsquo;s what done is supposed to look like.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>There&apos;s no catch</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/07/no-catch"/>
    <id>tag:test.feedfilters.net,2026-05-07:/blog/no-catch</id>
    <published>2026-05-07T12:00:00Z</published>
    <updated>2026-05-07T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>Why FeedFilters is free, why it&apos;s likely to stay free, and the smaller belief underneath the decision.</summary>
    <content type="html">&lt;p&gt;When I find a free service these days, my first instinct is to look
for the catch. There&amp;rsquo;s almost always a catch. Free up to N items,
then $9 a month. Free for personal use, then $19 for the &amp;ldquo;Pro&amp;rdquo; tier.
Free during the trial, then it auto-bills. The catch is rarely a
deal-breaker on its own — what&amp;rsquo;s exhausting is the discovery
process. Every new tool gets a &lt;em&gt;where&amp;rsquo;s-the-wall&lt;/em&gt; tax before I
commit to learning it.&lt;/p&gt;
&lt;p&gt;The Pricing tab on FeedFilters exists for people who think the same
way. Click it and the whole page is more or less one paragraph:
free, no ads, no tracking, no metered tier, no upsell coming later.
If you came looking for the catch, that page is where the not-catch
is documented.&lt;/p&gt;
&lt;p&gt;It was always going to be free. I registered the domain in 2012 and
sat on it for more than a decade, and when I finally sat down to
build the thing in 2026 I never seriously considered any other
pricing model. There was no comparison spreadsheet, no &lt;em&gt;what if a
freemium tier with N feeds and Y rules per month&lt;/em&gt;. The plan, going
all the way back, was always to put it out there for free.&lt;/p&gt;
&lt;p&gt;The reason is simpler than I&amp;rsquo;d like it to be: I wanted this thing
to exist, so I built it. Conceptually it isn&amp;rsquo;t hard. The model is
RSS, and RSS isn&amp;rsquo;t really moving — the standard hasn&amp;rsquo;t
meaningfully changed in two decades, and the filter primitive
(include and exclude tags) is bounded by what&amp;rsquo;s useful, not by what
the platform can support. Once the thing works, there isn&amp;rsquo;t a lot
of feature pressure. A working FeedFilters in 2028 should look a
lot like a working FeedFilters today. That&amp;rsquo;s a different shape than
a SaaS that has to keep adding features to keep retaining
customers; it&amp;rsquo;s closer to a utility that can sit in the background
and quietly do its job.&lt;/p&gt;
&lt;p&gt;The RSS community also has unusually good vibes about this kind of
thing. NetNewsWire is the example I keep coming back to: a
beautiful native feed reader, completely free, completely open
source, getting better year after year because the developer wants
to give the community a really nice feed reader. Pinboard is the
other one — not free, but $22 a year to keep the lights on,
with a service that&amp;rsquo;s deliberately minimal, almost to a fault. Both
of them feel like people who built something because they thought
it should exist, the way they thought it should be built.
Contributing something to that lineage is more interesting to me
than running a startup.&lt;/p&gt;
&lt;p&gt;The numbers work, too. Hosting is $5 a month at Linode. The domain
is $12.52 a year. I&amp;rsquo;ve load-tested the production configuration and
it can serve a few thousand active users without breaking a sweat.
If FeedFilters never grows past that, the costs come out of my
pocket and I won&amp;rsquo;t notice them. If it grows, I&amp;rsquo;ll probably add a
soft donation ask. Quick math: if one in a thousand active users
chipped in a dollar a month, the service could run indefinitely.
That&amp;rsquo;s the bar — not &lt;em&gt;make a living off this&lt;/em&gt;, not &lt;em&gt;build a
business&lt;/em&gt;, just &lt;em&gt;keep the lights on long enough for the thing to be
useful for a long time&lt;/em&gt;. If it turned out that fewer than one in a
thousand are willing to do that for a service they actively use,
I&amp;rsquo;d revisit. I hope it won&amp;rsquo;t come to that.&lt;/p&gt;
&lt;p&gt;Underneath all of that is a smaller belief I&amp;rsquo;ve been circling for a
while. Not everything has to be a product. Not everything has to
make money. Sometimes it&amp;rsquo;s better to just make something pure
— not riddled with limitations designed to be
just-frustrating-enough that you upgrade. Most software is
reasonable about this. Some software is generous about it. And
some of it is exhausting: a free tier so carefully calibrated that
every time I bump into a wall I can feel the dial that was turned
to put the wall there. The wall isn&amp;rsquo;t there because the software
couldn&amp;rsquo;t do the thing — it&amp;rsquo;s there because someone decided
that&amp;rsquo;s the right pain threshold to convert at. After enough of
that, &lt;em&gt;free&lt;/em&gt; in software stops meaning free and starts meaning &lt;em&gt;you
haven&amp;rsquo;t found the wall yet&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m not anti-business. Not everything can be free; software that
needs teams of people or expensive infrastructure has to find a way
to fund itself, and I&amp;rsquo;d like to make a living in this industry
too. But this particular project is small, cheap to run, and the
kind of thing I&amp;rsquo;d quietly resent paying for. So I made it the kind
of thing I wouldn&amp;rsquo;t quietly resent. You&amp;rsquo;re welcome to use it too, if
you want to. For free, no catch.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Sixteen mockups</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/06/sixteen-mockups"/>
    <id>tag:test.feedfilters.net,2026-05-06:/blog/sixteen-mockups</id>
    <published>2026-05-06T12:00:00Z</published>
    <updated>2026-05-06T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>The design pass — what I&apos;d hoped Claude could do, what it actually produced, and an honest assessment of where the AI fell short.</summary>
    <content type="html">&lt;p&gt;The lander I shipped with the day-two MVP was a placeholder. By
the time the rest of the system was solid — the load test,
the mail rebuild, the deployment overhaul — the homepage
was the only piece left that hadn&amp;rsquo;t gotten serious attention. So
I sat down to do the design pass.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/old-homepage.png&quot; alt=&quot;The pre-redesign homepage: a plain heading, a paragraph of body copy, a sign-up button, and three text-only sections describing what the service does and how it works.&quot;&gt;&lt;/p&gt;
&lt;p&gt;I had a hope going in. I&amp;rsquo;d been impressed enough with what Claude
could do across other parts of the project that I figured I&amp;rsquo;d
turn it loose on the design problem the same way: give it wide
latitude, ask for lots of variations, and pick what stuck. Color
scheme, font selection, page structure, illustrations, copy
— all of it. I thought I&amp;rsquo;d get a wealth of creative options
to react to, and I&amp;rsquo;d hone in from there.&lt;/p&gt;
&lt;p&gt;That isn&amp;rsquo;t really what happened.&lt;/p&gt;
&lt;p&gt;The first batch was sixteen mockups. They were technically
competent. The HTML was clean, the CSS was tidy, the layouts
held together. But they were also samey in ways I hadn&amp;rsquo;t
expected. They were heavy on text. They leaned toward a
particular shape of marketing page — hero with a bold
tagline, three benefit columns, a &amp;ldquo;how it works&amp;rdquo; diagram, a
final call-to-action above the fold. And almost every one of
them defaulted to a startupy register: pose the user&amp;rsquo;s problem,
sell them on it for a paragraph, position FeedFilters as the
solution.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/mockups-grid.png&quot; alt=&quot;A grid of all sixteen first-batch mockups at thumbnail size. Different palettes, fonts, and accent treatments — but every one of them is the same shape: a hero block with a bold tagline on the left, a polished mock content card on the right, the same column structure underneath.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Zoomed in, here&amp;rsquo;s what one of them looked like:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/mockup-startupy.png&quot; alt=&quot;Atlantic-blue mockup at readable size: kicker &amp;ldquo;A considered way to read the web,&amp;rdquo; a bold serif tagline &amp;ldquo;The reading you came for, cleanly delivered.&amp;rdquo;, a body paragraph framing the problem, a &amp;ldquo;Start filtering — it&amp;rsquo;s free&amp;rdquo; call-to-action, and a mock &amp;ldquo;Bon Appétit Daily&amp;rdquo; newsletter card to the right.&quot;&gt;&lt;/p&gt;
&lt;p&gt;I get why. The training data is full of startup landing pages,
and a startup landing page is a well-defined target. The
trouble is that FeedFilters isn&amp;rsquo;t a startup. It&amp;rsquo;s a personal
project I&amp;rsquo;m making available because I think it&amp;rsquo;s useful, and
because I want it to exist. The framing I needed wasn&amp;rsquo;t &amp;ldquo;here&amp;rsquo;s
how we solve your pain&amp;rdquo; — it was &amp;ldquo;I made this thing; if
you have the same RSS problem I have, maybe it&amp;rsquo;ll help you
too.&amp;rdquo; The voice and the visual register are different. Most of
the mockups were trying to sell to a stranger. I wanted
something that read as a recommendation from another RSS
reader.&lt;/p&gt;
&lt;p&gt;Once I figured that out, I stopped looking at mockups for a
while and wrote a positioning doc instead. Tagline, audience,
tone, what the page should and shouldn&amp;rsquo;t do. &lt;em&gt;Same sources,
less noise&lt;/em&gt; — that became the through-line. Audience:
people who already know they have an RSS problem and don&amp;rsquo;t need
to be sold on RSS. Tone: indie, low-key, Pinboard-adjacent.
Pricing: free with a soft donation ask, no paywall, no metered
tier. With that document in hand, the next round of mockups was
much easier to evaluate. A smaller second batch instead of
another sixteen, and picking the direction took a five-minute
look rather than a slog.&lt;/p&gt;
&lt;p&gt;After that, the work was straightforward. The lander shipped,
and then the rest of the app needed to catch up to it —
admin pages, feed list, settings, authentication flows —
which had always been part of the plan.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/new-homepage.png&quot; alt=&quot;The lander as it shipped: clean nav, a teal accent, the locked tagline &amp;ldquo;Same sources, less noise.&amp;rdquo;, a subhead, a sign-up button, and a before/after demo card showing a &amp;ldquo;National News&amp;rdquo; feed filtered by &amp;ldquo;exclude: politics, election, Congress&amp;rdquo;.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Walking the templates with Claude to surface where the new
palette and button system didn&amp;rsquo;t apply was efficient: it could
enumerate the inconsistencies, and I could decide which ones
mattered. Buttons, chips, cards, dark mode — all of that
landed cleanly once the direction was settled.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the feeds list page, before the design pass:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/old-feeds-list.png&quot; alt=&quot;The pre-redesign feeds list: a plain text top-bar (Home / Admin / Debug / Account / Log out), an unstyled &amp;ldquo;Your feeds&amp;rdquo; heading, plain bordered toolbar buttons, simple bordered cards for the Global filters block, the Dev and News folders, and each feed (with grey RSS-icon avatars and &amp;ldquo;No filter — passes everything through&amp;rdquo; subtext).&quot;&gt;&lt;/p&gt;
&lt;p&gt;And after:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/new-feeds-list.png&quot; alt=&quot;The redesigned feeds list: brand logo + FeedFilters in the topbar, the same content wrapped in a page-card, primary teal &amp;ldquo;Add Feed&amp;rdquo; button leading the toolbar, teal-accented Global filters card, polished folder and feed cards with hover-icon affordances, footer with About / Help / Blog / Privacy &amp;amp; Terms.&quot;&gt;&lt;/p&gt;
&lt;p&gt;One small detail in the lander captured the dynamic well. The
use-cases section is built around a few short phrases in the
shape &amp;ldquo;X, without the Y&amp;rdquo; — &lt;em&gt;national news, without the
politics; tech blogs, without the AI hype&lt;/em&gt;; that kind of
thing. Claude&amp;rsquo;s first take was to set them as plain text, one
sentence per line, in body type. The words were on the page,
but visually they didn&amp;rsquo;t do anything — they just sat
there next to the rest of the body copy.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/mockup-flat-text.png&quot; alt=&quot;The first take: each &amp;ldquo;X, without the Y&amp;rdquo; sentence rendered as a plain italicized line in a left-aligned list. Visually static; the words sit on the page with no rhythm or hierarchy.&quot;&gt;&lt;/p&gt;
&lt;p&gt;I asked whether they could appear inside cloud-shape bubbles,
the way thought bubbles do in a comic, breaking up the page&amp;rsquo;s
rhythm and letting the phrases read as ideas instead of
paragraphs. The result was a noticeable improvement. Same
sentences, totally different visual impact.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/05/06/sixteen-mockups/mockup-cloud-bubble.png&quot; alt=&quot;The redirected version: the same phrases set in rounded white pill-shaped bubbles, scattered across two columns with subtle shadows, and the matching &amp;ldquo;Your feeds, without the noise.&amp;rdquo; line below as the conclusion.&quot;&gt;&lt;/p&gt;
&lt;p&gt;I don&amp;rsquo;t think I&amp;rsquo;d have gotten there by asking for another
round of mockups; the prompt that led to the better version
had to contain the actual idea.&lt;/p&gt;
&lt;p&gt;The honest assessment is that this was the part of the project
where Claude was the least useful, by some distance. Generating
variation cheaply is a real strength, but in design that
variation needs an editor with taste, and the AI&amp;rsquo;s editorial
instincts kept pulling toward the trope I didn&amp;rsquo;t want.
Execution, once a direction is locked, is great. Originality
and judgment about what fits a non-business indie project is
where I had to do the work myself, and where I needed to slow
down to do it.&lt;/p&gt;
&lt;p&gt;I don&amp;rsquo;t think this is a permanent ceiling on what AI can do for
design. It&amp;rsquo;s possible another model is better at this kind of
work, or that the same model with better prompting would get
further. It&amp;rsquo;s also possible that design in this register is
the kind of thing that still genuinely needs a skilled human
designer. Where FeedFilters ended up is fine. It&amp;rsquo;s coherent,
it&amp;rsquo;s mine, it works. But I think the design has room to be
much better than it is, and if I ever do a more serious push
on the visual side, I&amp;rsquo;d reach for a person rather than a prompt.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>From scripts to infrastructure</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/05/from-scripts-to-infrastructure"/>
    <id>tag:test.feedfilters.net,2026-05-05:/blog/from-scripts-to-infrastructure</id>
    <published>2026-05-05T12:00:00Z</published>
    <updated>2026-05-05T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>How the deploy story went from a tangle of shell scripts to a tiered system of OpenTofu, Ansible, and GitHub Actions — and why the mail rebuild made the case to stop putting it off.</summary>
    <content type="html">&lt;p&gt;By the time the mail rebuild was finished, the way I was
deploying FeedFilters had grown into something I wasn&amp;rsquo;t proud
of. It started simply enough — a &lt;code&gt;deploy.sh&lt;/code&gt; that pushed
a fresh image to the production host, restarted the container,
and called it done. Then a &lt;code&gt;bootstrap.sh&lt;/code&gt; for getting a
brand-new host configured: Docker, a deploy user, the shared
Caddy network, the firewall. Then a &lt;code&gt;provision.sh&lt;/code&gt; for the
things &lt;code&gt;bootstrap.sh&lt;/code&gt; shouldn&amp;rsquo;t do at the same time. Then
sysctl tuning landed in &lt;code&gt;/etc/sysctl.d&lt;/code&gt; for the load-test work.
Then every time the app gained a new environment variable, the
production &lt;code&gt;.env&lt;/code&gt; had to be hand-edited to match the new shape.&lt;/p&gt;
&lt;p&gt;Each piece was small and made sense at the time. Together,
they&amp;rsquo;d become fragile. If &lt;code&gt;bootstrap.sh&lt;/code&gt; wasn&amp;rsquo;t carefully
idempotent, re-running it on a partially-provisioned host could
leave production in a worse state than it found it.
Hand-editing &lt;code&gt;.env&lt;/code&gt; on a live host is exactly the kind of thing
that goes wrong under deadline pressure. And the build was
still happening on the production host itself, which I&amp;rsquo;d never
been comfortable with — a mistake during build now meant a
sick production host.&lt;/p&gt;
&lt;h2 id=&quot;what-i-wanted&quot;&gt;What I wanted&lt;/h2&gt;
&lt;p&gt;What I wanted was for the box&amp;rsquo;s actual state to match a
description of it that lived somewhere other than the box.
Predictable provisioning. Image builds happening somewhere
besides the production server. A way to add or change
configuration without ssh-ing in. Most of all, I wanted to stop
worrying that I&amp;rsquo;d forget a step on the next deploy and find
out about it when something broke.&lt;/p&gt;
&lt;h2 id=&quot;options-considered&quot;&gt;Options considered&lt;/h2&gt;
&lt;p&gt;The two obvious shapes for this in 2026 are still Kubernetes
and a NixOS-style declarative-OS approach. I looked at both.&lt;/p&gt;
&lt;p&gt;Kubernetes is the obvious overkill answer. It&amp;rsquo;s a capable tool
with a real learning curve, and most of what makes it pay for
itself only starts to matter at multi-node scale or when there
are enough apps and enough operators to justify the complexity.
There&amp;rsquo;s also a resource cost: a usable cluster wants its own
host (or hosts) just to run the control plane, which would
mean a real step up in hosting costs for a tool I wasn&amp;rsquo;t yet
sure I needed. For one box running a handful of personal apps,
the tax wasn&amp;rsquo;t worth it.&lt;/p&gt;
&lt;p&gt;NixOS was the option I considered hardest. The
configuration-as-code promise is exactly what I was looking
for, and the idea of being able to roll a host forward and
back through versions of itself is genuinely appealing. But
once I dug into what it would actually take to get going
— including installing tooling on my development Mac
that wasn&amp;rsquo;t available through Homebrew — I hit the
brakes. That was the signal that I was getting in for more
than I&amp;rsquo;d bargained for, on a project where the goal was just
&amp;ldquo;make the box predictable.&amp;rdquo;&lt;/p&gt;
&lt;h2 id=&quot;the-shape-i-settled-on&quot;&gt;The shape I settled on&lt;/h2&gt;
&lt;p&gt;The system I ended up with has three pieces, each handling a
different layer of what&amp;rsquo;s running.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OpenTofu&lt;/strong&gt; owns the cloud side: the Linode host, the
Cloudflare DNS records (A, AAAA, MX, SPF, DKIM, DMARC, the
works), the reverse-PTR registration on the host&amp;rsquo;s IP, and the
other knobs the cloud APIs care about. Apply once, and what
the cloud thinks is true matches what the code says.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Ansible&lt;/strong&gt; owns the host side: Debian packages, the deploy
user, the shared &lt;code&gt;caddy&lt;/code&gt; Docker network, the sysctl values the
load-test work taught me to set, the docker-compose file that
pulls the app image at the right tag. A run takes the host from
whatever state it&amp;rsquo;s in to whatever the playbook describes,
without my having to remember which steps I already ran.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt; handles the build side. A merge to the
production branch builds the image, tags it, pushes it to
GHCR, and SSHs into the host to run a small &amp;ldquo;pull the new
image and restart the container&amp;rdquo; step. The build never touches
the production host. The deploy is a single idempotent step at
the end of a CI run.&lt;/p&gt;
&lt;p&gt;The choice between these tiers wasn&amp;rsquo;t obvious up front, and
I&amp;rsquo;d be lying if I said I&amp;rsquo;d planned to settle on this exact
combination. But each tool turned out to fit its layer well
enough that I haven&amp;rsquo;t been tempted to swap any of them out.&lt;/p&gt;
&lt;h2 id=&quot;the-mail-dovetail&quot;&gt;The mail dovetail&lt;/h2&gt;
&lt;p&gt;The mail rebuild made all of this a much easier trade to
justify. Sending mail reliably required DNS records I hadn&amp;rsquo;t
been managing in code — the MX, the SPF, the DKIM TXT
record at the right selector, the DMARC policy — and a
reverse-PTR registration on the host&amp;rsquo;s IP that matches the
sender hostname. Without all of those, deliverability suffers
in invisible ways. Doing them by hand across two web UIs
(Linode for rDNS, Cloudflare for DNS) every time the mail
config changed wasn&amp;rsquo;t realistic for long.&lt;/p&gt;
&lt;p&gt;With OpenTofu owning all of it, changes to the mail config and
the records they depend on go through the same change. The
DKIM key rotation that I&amp;rsquo;d been quietly avoiding became a small
edit instead of a half-day project.&lt;/p&gt;
&lt;h2 id=&quot;the-tiered-approach&quot;&gt;The tiered approach&lt;/h2&gt;
&lt;p&gt;The three layers turn out to map neatly onto the &amp;ldquo;one box,
many apps&amp;rdquo; deployment shape that the previous post talked
about. Cloud-level infrastructure (the box itself, the records
that point at it) is owned by OpenTofu. Host-level
configuration (Docker, Caddy, the shared network, sysctls, the
deploy user) is owned by Ansible. App-level deployment (the
binary image, its runtime config, its own DNS records) is
owned by GitHub Actions plus the per-app docker-compose file.&lt;/p&gt;
&lt;p&gt;When the next app arrives, only the third layer has to change.
The cloud is already there. The host is already provisioned.
The new app declares its compose stanza, its Caddy labels, its
image, and ships through the same pipeline FeedFilters uses.
That&amp;rsquo;s most of what I wanted from this work: a place where
adding the next app is a small, well-bounded job rather than a
re-derivation of the whole stack.&lt;/p&gt;
&lt;h2 id=&quot;looking-back&quot;&gt;Looking back&lt;/h2&gt;
&lt;p&gt;The fragility arc that motivated this is gone. I haven&amp;rsquo;t had
to ssh into production to fix something in days. The deploy is
predictable. New env variables go through code instead of
through the production host&amp;rsquo;s filesystem. And the cloud, the
host, and the app each have a single source of truth that
lives somewhere other than the box.&lt;/p&gt;
&lt;p&gt;The other thing I&amp;rsquo;m pleased with is that the system is durable
in the boring way. If the host disappeared tomorrow, OpenTofu
could rebuild the cloud half from its state file, Ansible
could provision a fresh host from the playbook, and the deploy
pipeline would put the app back where it was. I haven&amp;rsquo;t tested
that end-to-end, but every piece of it has been individually
verified during the build, and the gap between &amp;ldquo;I haven&amp;rsquo;t
tested it&amp;rdquo; and &amp;ldquo;I&amp;rsquo;m confident it works&amp;rdquo; is much smaller now
than it was a week ago.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Doing mail myself</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/04/doing-mail-myself"/>
    <id>tag:test.feedfilters.net,2026-05-04:/blog/doing-mail-myself</id>
    <published>2026-05-04T12:00:00Z</published>
    <updated>2026-05-04T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>Why FeedFilters sends its own mail, what direct-to-MX actually looks like, and the in-process SMTP listener that catches the bounces.</summary>
    <content type="html">&lt;p&gt;When I shipped the day-two MVP, the auth flows talked to a Postfix
instance bundled in the same container. I hadn&amp;rsquo;t thought hard
about it. SMTP felt like one of those problems other people had
already solved, and Postfix was the obvious thing to drop in.
Most testing happened in dev mode anyway, where the
email-verification token gets logged to stdout instead of mailed.
So Postfix was a placeholder — wired up, mostly unused,
sitting there waiting for me to come back to it.&lt;/p&gt;
&lt;p&gt;When I did come back, the question I ended up with wasn&amp;rsquo;t &amp;ldquo;how
should I configure Postfix&amp;rdquo; or &amp;ldquo;which mail-as-a-service should I
sign up for.&amp;rdquo; It was: how complicated is this actually?&lt;/p&gt;
&lt;h2 id=&quot;why&quot;&gt;Why&lt;/h2&gt;
&lt;p&gt;Mail-as-a-service is a healthy market. There are a handful of
vendors with usable free tiers, each with their own flavor of
REST API or proprietary SMTP shape. They&amp;rsquo;re well-engineered.
They handle deliverability, DKIM, suppression lists, all the
stuff people who actually do email a lot have to think about.&lt;/p&gt;
&lt;p&gt;The mail FeedFilters needs to send is small and boring, though.
Account verification when someone signs up. Password reset when
they ask for one. The occasional email-change confirmation.
That&amp;rsquo;s the entire surface. None of it is marketing email; none
of it has unsubscribe semantics; none of it cares about opens
and clicks. It&amp;rsquo;s a few transactional templates fired at the
user&amp;rsquo;s address when they ask for them.&lt;/p&gt;
&lt;p&gt;So the trade I was being asked to make was: take on a vendor
relationship, an external dependency, and an interface specific
to whoever I picked, in exchange for problems I don&amp;rsquo;t actually
have. SMTP is forty years old. It&amp;rsquo;s well-specified,
well-documented, and Go&amp;rsquo;s standard library can speak it out of
the box. I figured I&amp;rsquo;d see how far I could get on my own.&lt;/p&gt;
&lt;h2 id=&quot;what-doing-it-myself-actually-means&quot;&gt;What &amp;ldquo;doing it myself&amp;rdquo; actually means&lt;/h2&gt;
&lt;p&gt;The standard mail-sending shape is layered. Your app talks SMTP
to your relay; your relay does an MX lookup against the
recipient domain; your relay opens an SMTP connection to
whichever MX answers; your relay sends the message; if the
recipient bounces, the bounce comes back to your relay&amp;rsquo;s domain
and the relay tells you about it.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Direct-to-MX&lt;/em&gt; means the app does the middle steps itself. Look
up the recipient&amp;rsquo;s MX, open a connection to it, send the
message, handle the response. There&amp;rsquo;s no separate relay
process; the Go binary is the mail sender. For outbound-only
transactional traffic, that&amp;rsquo;s a remarkably small amount of
code, and Go&amp;rsquo;s &lt;code&gt;net/smtp&lt;/code&gt; and &lt;code&gt;net&lt;/code&gt; packages give you the
primitives.&lt;/p&gt;
&lt;p&gt;Mine ended up structured as an outbox queue with a worker.
Calls into the package don&amp;rsquo;t send synchronously; they enqueue a
row in &lt;code&gt;email_outbox&lt;/code&gt; and return. A background worker picks
rows up, does the MX lookup, opens a connection, opportunistically
does STARTTLS, sends, marks the row delivered or failed, and on
transient failures schedules a retry. On terminal failures the
row goes to &lt;em&gt;bounced&lt;/em&gt; with a reason. The worker is
fault-tolerant in the boring ways: it survives process restarts
(the queue is in SQLite, the same as everything else), and it
doesn&amp;rsquo;t lose messages on the kinds of network blips that make
SMTP miserable.&lt;/p&gt;
&lt;h2 id=&quot;dkim-signing&quot;&gt;DKIM signing&lt;/h2&gt;
&lt;p&gt;The flag that turned this from &amp;ldquo;works on test addresses&amp;rdquo; into
&amp;ldquo;actually deliverable&amp;rdquo; was DKIM. DKIM is a cryptographic
signature on the outbound message, generated with a private key
the sender holds. Receiving servers verify the signature
against a public key published as a DNS TXT record at
&lt;code&gt;&amp;lt;selector&amp;gt;._domainkey.&amp;lt;sender-domain&amp;gt;&lt;/code&gt;. Combined with SPF
(which says &amp;ldquo;this IP is allowed to send for this domain&amp;rdquo;) and
DMARC (which tells receivers what to do when SPF or DKIM
fail), it&amp;rsquo;s the way mail in 2026 establishes that it isn&amp;rsquo;t
forgery.&lt;/p&gt;
&lt;p&gt;In code it&amp;rsquo;s small. You generate a keypair, publish the public
half in DNS, sign each outbound message with the private half.
A Go library handles the protocol-level work; the integration
into the outbox is a flag on the config.&lt;/p&gt;
&lt;p&gt;In operations it&amp;rsquo;s bigger. The DNS records have to exist. The
keys have to live somewhere durable. The selector has to match.
Without all three, deliverability craters and your
password-reset emails go to spam — which I learned by
sending myself one. With all three, the major receivers
actually do honor good DKIM.&lt;/p&gt;
&lt;h2 id=&quot;catching-async-bounces&quot;&gt;Catching async bounces&lt;/h2&gt;
&lt;p&gt;Direct-to-MX has one major gap that a relay would normally
cover. Sometimes the recipient&amp;rsquo;s MX accepts a message —
returns 250 OK to the SMTP transaction — and then bounces
it asynchronously, after some downstream filter or quota check
rejects it. The recipient signals this by sending a DSN (a
delivery-status-notification email) back to the envelope
sender&amp;rsquo;s domain. If you&amp;rsquo;re running Postfix, Postfix accepts the
DSN, parses it, and writes the result to its logs. The standard
advice for &amp;ldquo;track bounces&amp;rdquo; is &amp;ldquo;tail those logs.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;That advice didn&amp;rsquo;t appeal to me. It meant coordinating between
Postfix and the app — running the right log-shipping or
log-parsing pipeline, getting the parsing right, dealing with
format changes across Postfix versions, and ending up with
bounce data in a place where it had to be stitched back to
outbox rows after the fact.&lt;/p&gt;
&lt;p&gt;The approach I took instead was to listen for the DSN myself.
The same Go process that sends mail also runs an SMTP server on
port 25, configured to accept incoming mail addressed to a
single bounce alias (&lt;code&gt;bounces@feedfilters.net&lt;/code&gt;). When a DSN
arrives, the listener parses it, looks up the original outbox
row by the bounce token embedded in the alias&amp;rsquo;s local part, and
updates the row to &lt;em&gt;bounced&lt;/em&gt; with the reason. No log shipping,
no parser maintenance, no inter-system coordination. The two
halves of the conversation — outbound delivery and inbound
bounce — happen in the same package, sharing the same
database.&lt;/p&gt;
&lt;p&gt;The DNS side is one MX record pointing at the host. The
container side is exposing port 25 with &lt;code&gt;cap_add: NET_BIND_SERVICE&lt;/code&gt; so the process can bind a privileged port.
That&amp;rsquo;s the entire infrastructure.&lt;/p&gt;
&lt;h2 id=&quot;the-outbox-as-the-dashboard&quot;&gt;The outbox as the dashboard&lt;/h2&gt;
&lt;p&gt;Because everything runs through that one &lt;code&gt;email_outbox&lt;/code&gt; table,
the table effectively &lt;em&gt;is&lt;/em&gt; the operational dashboard. The admin
UI&amp;rsquo;s Email page is a render of recent rows: id, recipient,
subject, current state (&lt;code&gt;pending&lt;/code&gt; / &lt;code&gt;delivered&lt;/code&gt; / &lt;code&gt;bounced&lt;/code&gt; /
&lt;code&gt;failed&lt;/code&gt;), the SMTP transcript or bounce reason, and how many
attempts it&amp;rsquo;s taken. You can click into any row and see exactly
what happened and when. There&amp;rsquo;s also a small &amp;ldquo;send a test
email&amp;rdquo; form at the top, which has been the single most
useful piece of UI for verifying live deliverability after a
DNS or DKIM change.&lt;/p&gt;
&lt;p&gt;None of this was a separate thing I built. It came almost free
from the queue-worker architecture — the data was already
there because the worker had to track state to do its job;
surfacing it as an admin view took a page handler and a
template. It&amp;rsquo;s now the first place I look when a user reports
they didn&amp;rsquo;t get an email.&lt;/p&gt;
&lt;p&gt;A small discipline worth mentioning: the body of the message is
wiped from the outbox row the moment it reaches a terminal
state. Before that, the body has to stay in the row so the
worker can retry. After that, there&amp;rsquo;s no operational reason to
keep it, and storing former password-reset email contents
around indefinitely would be its own kind of problem. The
status, the timestamps, and the bounce reason all stay; the
body goes.&lt;/p&gt;
&lt;h2 id=&quot;dropping-postfix&quot;&gt;Dropping Postfix&lt;/h2&gt;
&lt;p&gt;The last commit in the chapter was deleting the Postfix sidecar.
Once direct-to-MX was running cleanly in production for a few
hours, the sidecar wasn&amp;rsquo;t doing anything any of the live
traffic needed. Removing it took out a container, a
docker-compose stanza, a couple of network ports, and a
category of configuration drift I had been quietly nervous
about. The deployment shape is one app process now, no helper.&lt;/p&gt;
&lt;h2 id=&quot;looking-back&quot;&gt;Looking back&lt;/h2&gt;
&lt;p&gt;This was the most fun stretch of the project so far. The
problem space had the right shape for me — bounded,
well-specified, end-to-end testable, and full of operational
details that were actually interesting to figure out. The end
result is one Go package, one SQLite table, and one MX record.
It does what mail-as-a-service was going to do for me, except
I understand every part of it and nothing in the path costs me
money or knows anything about my users beyond the fact that
they signed up for a feed-filtering app.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a worthwhile trade for FeedFilters. It might not be for
a service that sends millions of marketing emails, where
deliverability work is ongoing and a vendor&amp;rsquo;s reputation engine
pays for itself. For an indie app sending a handful of
transactional emails to people who asked for them, doing it
yourself turns out to be reasonable.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Easier now than later</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/05/02/easier-now-than-later"/>
    <id>tag:test.feedfilters.net,2026-05-02:/blog/easier-now-than-later</id>
    <published>2026-05-02T12:00:00Z</published>
    <updated>2026-05-02T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>The architectural moves I made after load testing — subscriber URLs that won&apos;t break, a shared reverse proxy, versioned static assets, and a quieter operational shape — and the design instinct underneath them.</summary>
    <content type="html">&lt;p&gt;Load testing showed me where the walls were. Most of the work
that came out of it was about pushing those walls out, which is
what the &lt;a href=&quot;/blog/2026/04/30/pointing-load-at-it&quot;&gt;previous post&lt;/a&gt;
covers. But a separate batch of work landed in the same window
that wasn&amp;rsquo;t really about capacity. It was about giving the
service room to grow without painful migrations later.&lt;/p&gt;
&lt;p&gt;The thread tying these decisions together is asymmetry. Each
one is nearly free to do up front and expensive (or impossible)
to do once the service has subscribers, deploy history, or
other apps running alongside it. &amp;ldquo;Now or never&amp;rdquo; is the honest
reason these landed when they did.&lt;/p&gt;
&lt;h2 id=&quot;the-feeds-subdomain&quot;&gt;The feeds subdomain&lt;/h2&gt;
&lt;p&gt;The most load-bearing of these is splitting feed serving onto
its own subdomain. In production, FeedFilters now serves the
app and admin surfaces at &lt;code&gt;feedfilters.net&lt;/code&gt; and feed output at
&lt;code&gt;feeds.feedfilters.net&lt;/code&gt;. Today, both hostnames route to the same
backend container by host-header dispatch — a few lines in
the router. Tomorrow, if feed output ever needs more capacity
than the app box can deliver, that subdomain can move to its
own dedicated host (or hosts) without breaking anyone.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Without breaking anyone&amp;rdquo; is the whole point. RSS readers
remember subscription URLs. Once a user subscribes, their
reader has the URL pinned; if I later need to change the URL
shape to move feed serving elsewhere, every subscriber&amp;rsquo;s reader
has to re-subscribe to the new URL. That&amp;rsquo;s the kind of
operational debt I&amp;rsquo;d like to avoid.&lt;/p&gt;
&lt;h2 id=&quot;caddy-as-a-shared-reverse-proxy&quot;&gt;Caddy as a shared reverse proxy&lt;/h2&gt;
&lt;p&gt;The deployment shape FeedFilters runs in is &amp;ldquo;one box, many
apps.&amp;rdquo; A Caddy reverse proxy on the host owns ports 80 and 443
and routes to backend containers based on labels they declare
in their compose files. Each app says, in effect, &amp;ldquo;I serve
&lt;code&gt;feedfilters.net&lt;/code&gt;, &lt;code&gt;www.feedfilters.net&lt;/code&gt;, and
&lt;code&gt;feeds.feedfilters.net&lt;/code&gt;; here&amp;rsquo;s my upstream port.&amp;rdquo; Caddy reads
the labels off the shared &lt;code&gt;caddy&lt;/code&gt; docker network, sorts out
TLS automatically, and dispatches.&lt;/p&gt;
&lt;p&gt;The reason this matters for scale isn&amp;rsquo;t the load —
FeedFilters fits on a single Linode Nanode comfortably. It&amp;rsquo;s
that my server exists as a place for more apps over time. When
the next app arrives, it joins the same caddy network, declares
its hostnames, and ships. No new TLS work, no new
reverse-proxy config to maintain, no per-app port management.
The first app pays for setting up the shared proxy; every app
after that gets it for free.&lt;/p&gt;
&lt;h2 id=&quot;versioned-static-assets&quot;&gt;Versioned static assets&lt;/h2&gt;
&lt;p&gt;In the same spirit, every URL emitted into a template that
points into &lt;code&gt;/static/&lt;/code&gt; — the stylesheet, the favicon, the
chart.js bundle, the fallback icons — now carries a
server-start nanosecond timestamp as a query parameter. A new
binary on deploy mints a new timestamp, which mints new URLs,
which forces every client to refetch.&lt;/p&gt;
&lt;p&gt;Because URLs change on deploy and old ones simply stop being
requested, the cache headers on &lt;code&gt;/static/*&lt;/code&gt; go from a
conservative five-minute TTL to one-year immutable. Browsers
cache aggressively, revalidate nothing in steady state, and
pick up the new asset automatically the next time a page
loads. This is the standard pairing for hashed or versioned
static assets, but moving to it required the URL versioning to
be in place first.&lt;/p&gt;
&lt;h2 id=&quot;operational-discipline&quot;&gt;Operational discipline&lt;/h2&gt;
&lt;p&gt;Two smaller pieces in the same vein, each cheap to do once
and worthwhile permanently.&lt;/p&gt;
&lt;p&gt;The compose file&amp;rsquo;s healthcheck now hits &lt;code&gt;/healthz&lt;/code&gt;. Without it,
a crashed app inside a multi-process container could read as
&amp;ldquo;Up 11 hours&amp;rdquo; in &lt;code&gt;docker compose ps&lt;/code&gt; because supervisord was
happily keeping the postfix sidecar alive. The healthcheck
makes the operational view honest.&lt;/p&gt;
&lt;p&gt;Each successful deploy gets an annotated tag of the form
&lt;code&gt;deploy/&amp;lt;timestamp&amp;gt;-&amp;lt;sha&amp;gt;&lt;/code&gt;. Back-to-back promotes during a
queued deploy can supersede each other — not every commit
on the production branch necessarily ships — so the tag
is the chronological ledger of what actually went live.
&lt;code&gt;git tag -l &apos;deploy/*&apos;&lt;/code&gt; is a workable audit trail.&lt;/p&gt;
&lt;h2 id=&quot;the-instinct-underneath&quot;&gt;The instinct underneath&lt;/h2&gt;
&lt;p&gt;Looking at the whole batch together, there&amp;rsquo;s a thread I want
to call out. None of these are really about adding capacity.
They&amp;rsquo;re about reducing the number of things I have to keep
tuning, keep remembering, or keep working around when the
service grows. The previous post described a load shedder that
reads memory pressure directly and a parsed-feed cache that
sizes itself by body length; this post adds a deployment shape
that doesn&amp;rsquo;t need per-app reverse-proxy tweaks, a static-asset
story that doesn&amp;rsquo;t need cache-bust commits, and a feeds origin
that won&amp;rsquo;t break subscribers when the topology underneath it
changes.&lt;/p&gt;
&lt;p&gt;The instinct under both posts is the same. Build the parts of
the system that are likely to need to grow so that growing them
doesn&amp;rsquo;t require remembering anything. If I ever do need to
scale FeedFilters up, I want the answer to be &amp;ldquo;give it better
hardware,&amp;rdquo; not &amp;ldquo;spend a weekend unwinding decisions I made on
day two.&amp;rdquo;&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Pointing load at it</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/04/30/pointing-load-at-it"/>
    <id>tag:test.feedfilters.net,2026-04-30:/blog/pointing-load-at-it</id>
    <published>2026-04-30T12:00:00Z</published>
    <updated>2026-04-30T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>What load testing surfaced — kernel limits, write-queue cascades, hot spots in the parse path, race conditions, and a load shedder that had to learn to be graceful.</summary>
    <content type="html">&lt;p&gt;With the admin dashboard in place, I started load testing. I wanted
to find the actual ceiling for FeedFilters on a 1 GB Linode Nanode
— the smallest production target I cared about — and
to understand what would break before I got there. What followed
was the most concentrated stretch of work in the project. Every
day surfaced something I hadn&amp;rsquo;t expected, and most of those
&amp;ldquo;somethings&amp;rdquo; turned into commits that made the service substantially
better.&lt;/p&gt;
&lt;p&gt;This post tries to cover the full arc.&lt;/p&gt;
&lt;h2 id=&quot;the-harness&quot;&gt;The harness&lt;/h2&gt;
&lt;p&gt;The load is synthetic, but it&amp;rsquo;s substantial. There are no real
users involved — everything below is driven by a controlled
test rig — but the rig is sized to push the service well
past its comfortable operating range, and the lessons that came
out of it are real.&lt;/p&gt;
&lt;p&gt;The harness has three pieces. A &lt;strong&gt;mock upstream&lt;/strong&gt; that serves
deterministic RSS / Atom / JSON feeds, with knobs for body shape,
delay, hang, errors, and gzip. A &lt;strong&gt;runner&lt;/strong&gt; with a small web UI
that drives the test and aggregates metrics. And the &lt;strong&gt;SUT&lt;/strong&gt;
— a debug build of FeedFilters running in a near-production
container shape. The mock and runner live on a separate box from
the SUT so the measurement isn&amp;rsquo;t competing with the workload for
the only CPU the production target has.&lt;/p&gt;
&lt;p&gt;The runner has two modes. &lt;em&gt;Classic&lt;/em&gt; is the textbook shape: fixed
user count, fixed ramp, stop when p95 latency crosses a threshold.
&lt;em&gt;Adaptive&lt;/em&gt; is more interesting — a controller that seeks
the ceiling and tracks it, ramping load up while the SUT looks
healthy and backing off when it doesn&amp;rsquo;t. Most of the useful tests
I ran were in adaptive mode: the system tells you where the cliff
is rather than you having to guess.&lt;/p&gt;
&lt;h2 id=&quot;os-limits-before-anything-else&quot;&gt;OS limits, before anything else&lt;/h2&gt;
&lt;p&gt;The first thing the load test surfaced was that my host wasn&amp;rsquo;t
configured for traffic. Two kernel sysctls turned out to matter
right away.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;net.core.somaxconn&lt;/code&gt; defaults to 128 — the size of the TCP
accept queue. Under any meaningful burst of new connections, the
queue fills up and connections get refused outright. Bumping it
to 65535 stopped the spurious &lt;code&gt;ECONNREFUSED&lt;/code&gt; errors immediately.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;net.netfilter.nf_conntrack_max&lt;/code&gt; defaults to about 65K, which the
service blew through during outbound-fetch storms. The symptom
was confusing: DNS lookups started failing with &lt;code&gt;write: operation not permitted&lt;/code&gt;. That turned out to be conntrack refusing to
allocate a slot for a new outbound flow. Setting it to 524288
fixed it.&lt;/p&gt;
&lt;p&gt;Both of these have to be applied per-container as well as on the
host, because Docker gives each container its own network
namespace and &lt;code&gt;somaxconn&lt;/code&gt; is per-namespace. The host setting
doesn&amp;rsquo;t propagate.&lt;/p&gt;
&lt;h2 id=&quot;the-connection-pool-cliff&quot;&gt;The connection-pool cliff&lt;/h2&gt;
&lt;p&gt;Once the kernel was out of the way, the next thing the runner
found was a ceiling that didn&amp;rsquo;t look like a ceiling at first.
Throughput would climb steadily, then collapse — not just
stop scaling, but actively drop into a death spiral with the box
going OOM.&lt;/p&gt;
&lt;p&gt;The cause was that I&amp;rsquo;d left &lt;code&gt;sql.DB&lt;/code&gt;&amp;rsquo;s connection pool unbounded.
SQLite serializes writers (one writer at a time, full stop), so
under a write-heavy burst every blocked goroutine grabbed a fresh
connection from the pool. A brief queue cascaded into thousands
of open connections, each carrying its own buffers and goroutine
stack and lock-manager bookkeeping. That memory pressure produced
GC pressure, which produced more queueing, which produced more
connections. The system tipped over.&lt;/p&gt;
&lt;p&gt;The fix was a one-line change with a lot of reasoning behind it:
cap the pool at 25. Twenty-five connections gives WAL readers
plenty of headroom (they don&amp;rsquo;t block each other) while pinning a
hard ceiling on the writer queue. Excess request rate above that
queues in Go&amp;rsquo;s scheduler instead of in SQLite&amp;rsquo;s lock manager,
which is dramatically cheaper. There&amp;rsquo;s no principled formula
behind 25; it&amp;rsquo;s empirically derived from capacity sweeps on the
1 GB Nanode that&amp;rsquo;s the reference deployment. Bigger boxes can
likely run higher, but no one&amp;rsquo;s swept that.&lt;/p&gt;
&lt;h2 id=&quot;profiling-and-the-hot-spot-day&quot;&gt;Profiling and the hot-spot day&lt;/h2&gt;
&lt;p&gt;With the kernel and the pool fixed, the SUT ran clean enough that
the next ceiling was about CPU and memory inside FeedFilters
itself. I&amp;rsquo;d already wired in &lt;code&gt;pprof&lt;/code&gt; for dev builds, so I pointed
it at a sweep and looked at where the cycles were going.&lt;/p&gt;
&lt;p&gt;It was instructive. The output path — where a feed reader
fetches a filtered feed — was spending most of its time in
two places I hadn&amp;rsquo;t expected: re-parsing the same upstream XML on
every request, and re-normalizing every item&amp;rsquo;s text on every
filter pass. The output endpoint was, in effect, paying full
parse-and-filter cost for every cache hit.&lt;/p&gt;
&lt;p&gt;Several changes landed in a single day:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cache the parsed feed itself.&lt;/strong&gt; Alongside the disk-backed
HTTP cache for upstream fetches, an in-memory byte-bounded LRU
holds the parsed &lt;code&gt;gofeed.Feed&lt;/code&gt; plus per-item byte ranges into
the source XML. Cache hits skip the parser entirely; misses pay
full parse cost once and amortize across every reader.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache normalized item text.&lt;/strong&gt; The filter engine compares
case-folded, accent-stripped item text. Doing that work inside
the filter loop meant doing it for every (item × filter)
combination. Caching the normalized text per item, once, on
parse, dropped filter-pass CPU substantially.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In-memory cache for &lt;code&gt;Store.GetByID&lt;/code&gt;.&lt;/strong&gt; The output handler
was hitting SQLite for the feed row on every request. The row
changes rarely; an in-memory cache keyed by feed ID eliminated
the lookup from the hot path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Slim the cache value type.&lt;/strong&gt; The original &lt;code&gt;FeedCache&lt;/code&gt; value
carried more than it needed to. Trimming it let the same heap
budget hold roughly five times as many entries, which cut the
steady-state miss rate proportionally.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are clever individually. Together they shifted the
output endpoint from CPU-bound to nearly memory-bound, which is
the regime I wanted.&lt;/p&gt;
&lt;h2 id=&quot;an-upstream-cache&quot;&gt;An upstream cache&lt;/h2&gt;
&lt;p&gt;Around the same time, I replaced the day-2 source cache with a
new disk-backed HTTP cache (&lt;code&gt;internal/httpcache&lt;/code&gt;). The original
two-state-machine design — metadata in SQLite, bytes on
disk — had race windows between the two halves that the
load test exposed. The new cache is interesting enough to
deserve its own post, so I&amp;rsquo;ll write that one separately. For
the purposes of this post, the relevant fact is that upstream
feed responses are cached on disk and concurrent requests for
the same URL coalesce to a single fetch.&lt;/p&gt;
&lt;h2 id=&quot;debouncing-the-timestamp-writes&quot;&gt;Debouncing the timestamp writes&lt;/h2&gt;
&lt;p&gt;At sustained cache-hit load — around 200 requests per
second — every &lt;code&gt;/feed/{id}&lt;/code&gt; request was firing a
synchronous &lt;code&gt;UPDATE&lt;/code&gt; on &lt;code&gt;feeds.last_successful_fetch_at&lt;/code&gt;. With
the connection pool capped at 25 and SQLite&amp;rsquo;s single-writer
model, every UPDATE serialized through one writer lock. The
runner started seeing &lt;code&gt;SQLITE_BUSY&lt;/code&gt; errors and Caddy began
returning 502s as the in-process queue grew.&lt;/p&gt;
&lt;p&gt;The fix was a coalescing flusher: a &lt;code&gt;FetchRecorder&lt;/code&gt; accumulates
&amp;ldquo;feed N was just fetched successfully&amp;rdquo; events into a per-feed-id
map, and flushes a snapshot of that map in a single transaction
every two seconds. Latest-event-wins is fine for what those
columns are: advisory timestamps and an icon URL that almost
never changes. N writes become one writer-lock acquire. The hot
path&amp;rsquo;s contribution drops to one map insert under a mutex.&lt;/p&gt;
&lt;p&gt;Two seconds of staleness on &lt;code&gt;last_successful_fetch_at&lt;/code&gt; is
invisible to admins; the writer-contention cliff disappeared
completely.&lt;/p&gt;
&lt;h2 id=&quot;load-shedding-from-binary-to-graded&quot;&gt;Load shedding: from binary to graded&lt;/h2&gt;
&lt;p&gt;Once the box got close to its memory ceiling, even the optimized
service would eventually fall over. So I added a load shedder
— a middleware on the public feed endpoint that returns
503 + &lt;code&gt;Retry-After&lt;/code&gt; when the system is under unsustainable
pressure.&lt;/p&gt;
&lt;p&gt;The first cut was binary. When &lt;code&gt;MemAvailable&lt;/code&gt; (read from
&lt;code&gt;/proc/meminfo&lt;/code&gt; every 200 ms) dropped below a configured floor,
flip a flag on; when it climbed back above floor × 1.5, flip the
flag off. Hysteresis was supposed to prevent flapping. It didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;The failure mode was destructive. The flag flipped on at the
floor, GC freed memory, the flag flipped off, a flood of queued
requests poured in, memory dropped back to the floor, the flag
flipped on again. Each &amp;ldquo;off&amp;rdquo; cycle&amp;rsquo;s flood had to be processed
— allocating buffers, spawning goroutines, growing
in-flight count — before the shedder could catch up.
Eventually a flood arrived that the box couldn&amp;rsquo;t keep up with,
and we cascaded.&lt;/p&gt;
&lt;p&gt;The replacement is a continuous probability instead of a flag.
Each request is shed with probability &lt;code&gt;p&lt;/code&gt;, where &lt;code&gt;p&lt;/code&gt; is a
linear interpolation between two thresholds: a &lt;em&gt;soft&lt;/em&gt; floor
(where shedding starts at 0%) and a &lt;em&gt;hard&lt;/em&gt; floor (where shedding
reaches 100%). As &lt;code&gt;MemAvailable&lt;/code&gt; drops from 100 MiB toward 50
MiB, shed probability climbs from 0% to 100%. The system finds
an equilibrium: shed rate matches the fraction by which incoming
load exceeds sustainable load. No oscillation, no flood-cycles.&lt;/p&gt;
&lt;p&gt;CPU got the same treatment using &lt;code&gt;load1 / numCPU&lt;/code&gt; as the signal,
with its own soft and hard thresholds. The shedder reports
&lt;em&gt;which&lt;/em&gt; signal is dominating, so an operator can tell why the
system is shedding rather than just that it is.&lt;/p&gt;
&lt;p&gt;This was the single piece of work where I most clearly felt the
difference between &amp;ldquo;code that compiles and runs&amp;rdquo; and &amp;ldquo;code that
behaves well under pressure.&amp;rdquo; The first version did the second
thing badly. Getting it right took thinking about feedback loops,
not just thresholds.&lt;/p&gt;
&lt;h2 id=&quot;recovery-without-intervention&quot;&gt;Recovery without intervention&lt;/h2&gt;
&lt;p&gt;The point of all this is that the system has to handle being
overloaded without me being there. If I&amp;rsquo;m asleep and a feed reader
client misbehaves and starts hammering the box, the service has
to shed enough load to stay functional, ride out the storm, and
return to its normal state without me logging in.&lt;/p&gt;
&lt;p&gt;The hard part of that, on a small box, is staying out of swap.
Once the kernel starts paging memory to disk, latencies blow up
by orders of magnitude, the in-flight queue grows because
requests aren&amp;rsquo;t clearing, the queue eats more memory, and you&amp;rsquo;re
in a spiral the shedder can&amp;rsquo;t get you out of fast enough. The
whole game is to shed &lt;em&gt;before&lt;/em&gt; swap starts, not after.&lt;/p&gt;
&lt;p&gt;That puts a constraint on the shedder&amp;rsquo;s signal. &lt;code&gt;MemAvailable&lt;/code&gt; is
the right thing to read — it&amp;rsquo;s the kernel&amp;rsquo;s accounting of
&amp;ldquo;how much memory can you get without paging.&amp;rdquo; But there&amp;rsquo;s a
complication: Go&amp;rsquo;s garbage collector, by default, has no soft
heap cap. On a 768 MiB cgroup, a healthy steady-state load will
let the heap drift up until &lt;code&gt;MemAvailable&lt;/code&gt; is sitting around 100
MiB — not because anything is wrong, but because that&amp;rsquo;s
where the GC decides it should be.&lt;/p&gt;
&lt;p&gt;The first multi-phase capacity sweep tripped 100% shed on every
test within forty seconds for exactly that reason. The shedder
was reading &amp;ldquo;memory pressure&amp;rdquo; off natural GC headroom. The fix
wasn&amp;rsquo;t to make the shedder less aggressive; it was to give the
GC a target. Setting &lt;code&gt;GOMEMLIMIT&lt;/code&gt; to about 70% of the cgroup
limit (550 MiB on a 768 MiB container) tells Go&amp;rsquo;s GC to keep the
heap under that, leaving real margin in the cgroup for genuine
spikes. With the heap bounded, &lt;code&gt;MemAvailable&lt;/code&gt; in healthy state
sits at 200–300 MiB, and a floor of 50 MiB becomes a
meaningful &amp;ldquo;about to swap&amp;rdquo; margin instead of a false-positive
trigger.&lt;/p&gt;
&lt;p&gt;Combined with the graded shedder, this gives the box a recovery
shape I&amp;rsquo;m pleased with. Real memory pressure ramps shed
probability up; serving load drops; GC catches up; pressure
eases; shed probability ramps back down; serving resumes.
Healthy state and overloaded state are the same code path,
different sliders. There&amp;rsquo;s no &amp;ldquo;panic mode&amp;rdquo; the system has to
manually exit.&lt;/p&gt;
&lt;p&gt;The container itself runs with &lt;code&gt;memswap_limit&lt;/code&gt; equal to
&lt;code&gt;mem_limit&lt;/code&gt;, which disables swap inside the container regardless
of whether the host has any. If the cgroup limit is 768 MiB,
768 MiB is the actual ceiling — no slow-spiral page-out
behavior is possible. That&amp;rsquo;s the belt to the shedder&amp;rsquo;s suspenders.&lt;/p&gt;
&lt;h2 id=&quot;race-conditions&quot;&gt;Race conditions&lt;/h2&gt;
&lt;p&gt;A couple of weeks after the bulk of the load-testing work, two
audit passes through the cache code and a survey of about 7000
real-world feeds turned up bugs that hadn&amp;rsquo;t shown up in the
synthetic runs.&lt;/p&gt;
&lt;p&gt;In the HTTP cache:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The leader of a coalesced fetch was protected against the
evictor by an in-flight registration held by the &lt;em&gt;consumer&lt;/em&gt;&amp;rsquo;s
&lt;code&gt;Fetch&lt;/code&gt; defer. When every consumer cancelled before the leader
finished, the refcount went to zero and the evictor was free
to unlink the cache file out from under the leader&amp;rsquo;s &amp;ldquo;open the
cache file&amp;rdquo; path. Fix: the leader registers its own
protection.&lt;/li&gt;
&lt;li&gt;A handful of WordPress installs returned &lt;code&gt;304 Not Modified&lt;/code&gt; to
plain &lt;code&gt;GET&lt;/code&gt; requests with no &lt;code&gt;If-*&lt;/code&gt; headers and no prior cache
entry. The 304 branch tried to open a file that never existed.
Fix: gate the 304 branch on actually having a cache entry, and
surface a clear error when we don&amp;rsquo;t.&lt;/li&gt;
&lt;li&gt;The evictor&amp;rsquo;s &lt;code&gt;filepath.Walk&lt;/code&gt; callback was returning &lt;code&gt;walkErr&lt;/code&gt;
unconditionally. Any concurrent &lt;code&gt;rm&lt;/code&gt; between &lt;code&gt;readdir&lt;/code&gt; and the
callback&amp;rsquo;s &lt;code&gt;stat&lt;/code&gt; produced an &lt;code&gt;ENOENT&lt;/code&gt; that aborted the entire
eviction tick.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;snapshot()&lt;/code&gt; and &lt;code&gt;reset()&lt;/code&gt; on the cache stats actor used
unbuffered channels. Calling either after &lt;code&gt;Close&lt;/code&gt; deadlocked
forever.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In SQLite-land, three call sites had a pattern that looked
innocuous but turned out to be a landmine: a deferred &lt;code&gt;BeginTx&lt;/code&gt;,
a &lt;code&gt;SELECT&lt;/code&gt;, and then an &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;UPDATE&lt;/code&gt;. SQLite returns
&lt;code&gt;SQLITE_BUSY&lt;/code&gt; immediately when a transaction tries to upgrade
from &lt;code&gt;SHARED&lt;/code&gt; to &lt;code&gt;RESERVED&lt;/code&gt; while another writer is active, and
— this is the cruel part — &lt;code&gt;busy_timeout&lt;/code&gt; doesn&amp;rsquo;t
retry transaction upgrades. The fetch-batch flusher I&amp;rsquo;d added
to fix the timestamp-write contention was just frequent enough
to clash with these read-then-write transactions and fail them
outright. The fix was to restructure each into a single
statement (&lt;code&gt;UPDATE...RETURNING&lt;/code&gt; with every gate in the &lt;code&gt;WHERE&lt;/code&gt;)
or a write-only transaction.&lt;/p&gt;
&lt;p&gt;These weren&amp;rsquo;t surfaced by the load test. They were surfaced by
later, more careful auditing — with the load test having
taught me what to look for.&lt;/p&gt;
&lt;h2 id=&quot;the-bottleneck&quot;&gt;The bottleneck&lt;/h2&gt;
&lt;p&gt;One thing the load test resolved that I hadn&amp;rsquo;t been certain
about: where the box runs out. On the 1 GB Nanode at the
optimized steady state, FeedFilters is CPU-bound. Memory and
disk I/O have headroom; the single vCPU is what saturates first.
That&amp;rsquo;s a useful answer because it tells me what scaling looks
like — if FeedFilters needs more capacity than this box
can deliver, the answer is more CPU, not more memory and not a
different storage tier.&lt;/p&gt;
&lt;p&gt;The rough numbers that came out of the capacity sweeps: at a
workload of 25 feeds per user polling every five minutes —
more aggressive than what most readers do in practice —
the box stays under a 5% shed rate up through about 5,000
simultaneous active users. That&amp;rsquo;s roughly 125,000 active feed
subscriptions and several hundred requests per second sustained.
Past that, the shedder kicks in to keep the box upright, but
the system isn&amp;rsquo;t serving every request anymore. That&amp;rsquo;s plenty
of headroom for FeedFilters to spend a long time at one box.&lt;/p&gt;
&lt;h2 id=&quot;what-i-came-out-with&quot;&gt;What I came out with&lt;/h2&gt;
&lt;p&gt;A couple of weeks ago, FeedFilters was a working app I&amp;rsquo;d
shipped. Now it&amp;rsquo;s a working app I have rough numbers for:
capacity, where it breaks, what breaks first, what to do when
it does. That&amp;rsquo;s a different kind of confidence.&lt;/p&gt;
&lt;p&gt;The next post is about the architectural decisions that came
out of all this — the moves I made to give the service
room to grow before any growing actually has to happen.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Knobs and graphs</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/04/22/knobs-and-graphs"/>
    <id>tag:test.feedfilters.net,2026-04-22:/blog/knobs-and-graphs</id>
    <published>2026-04-22T12:00:00Z</published>
    <updated>2026-04-22T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>Why the admin dashboard had to come before load testing, and what ended up in it.</summary>
    <content type="html">&lt;p&gt;The MVP shipped, the basic flows worked, and the next thing on my
mind was load testing — pointing a synthetic workload at the
service and seeing where it bent. I knew that wasn&amp;rsquo;t going to tell
me much unless I could see what was happening inside the service in
real time and turn the knobs that were likely to matter. So before
I started load testing, I built out an admin dashboard.&lt;/p&gt;
&lt;p&gt;Three things in particular had to exist before I could start. First,
I needed a lot of visibility — enough instrumentation to
actually identify a bottleneck when one showed up. Second, I needed
knobs I could turn at runtime, not at deploy time, so I could try a
different limit and see the effect immediately. Third, I needed an
easy way to reset the database, so I could run an experiment, blow
it away, change a setting, and try again without worrying about
leaving the system in a weird state.&lt;/p&gt;
&lt;p&gt;The admin sidebar today has six sections: Stats (basic counts),
Users (the user list and per-user detail), Runtime config (the
tunable knobs), Recent errors (the last N application errors with
context), Email (the outbox dashboard), and Metrics (the graphs).
Most of those existed in basic form on day two of the build. The
Metrics page is where most of the load-test prep work went.&lt;/p&gt;
&lt;p&gt;I tried to graph as many things as I could think of. Request and
fetch latencies, error rates, cache hit rates, memory and load,
goroutines and file descriptors, database sizes, signup and
feed-churn rates — the Metrics page is, in effect, a wall of
small charts. The point isn&amp;rsquo;t that any one of them is
indispensable. The point is that during a load test I want to be
able to glance at the screen and see immediately &lt;em&gt;where&lt;/em&gt; the
service is hurting, rather than guess. And in steady-state
production, the same dashboard tells me whether the thing is
healthy without me logging into a host to find out.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/04/22/knobs-and-graphs/metrics.png&quot; alt=&quot;A section of the Metrics page, showing six of its small charts: system memory used, system load, goroutines, open file descriptors, database sizes, and HTTP cache size.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The Runtime config page is the other piece I&amp;rsquo;m pleased with. Most
of the limits I&amp;rsquo;d otherwise have to redeploy to change —
rate limits, connection pool sizes, fetcher concurrency, cache TTL
overrides — are tunable from a form. Save the form, the
change takes effect on the next sample tick (a fraction of a
second later). When you&amp;rsquo;re load testing, the ability to change a
limit and immediately watch the dashboard react is the difference
between guessing and knowing. It also means I can do the same
thing in production if I ever need to.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/blog/2026/04/22/knobs-and-graphs/runtime-config.png&quot; alt=&quot;The top of the Runtime config page: a setting/value table with token TTLs, retention windows, and per-IP rate limits, each tunable inline. A note at the top reminds the operator that changes take effect immediately.&quot;&gt;&lt;/p&gt;
&lt;p&gt;The database reset belongs in the same category. It&amp;rsquo;s not glamorous
— a debug-mode endpoint with a confirmation prompt —
but being able to wipe the slate without rebuilding the container
or shelling into the host changes how willing I am to run risky
experiments. That willingness is most of what makes load testing
actually useful.&lt;/p&gt;
&lt;p&gt;The graphs, the live knobs, and the easy reset are what made load
testing feasible. That&amp;rsquo;s the next post.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Passkeys, twice</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/04/21/passkeys-twice"/>
    <id>tag:test.feedfilters.net,2026-04-21:/blog/passkeys-twice</id>
    <published>2026-04-21T12:00:00Z</published>
    <updated>2026-04-21T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>Why FeedFilters defaults to passkeys, the design choice that makes them replace passwords, and the two iterations it took to get the UX right.</summary>
    <content type="html">&lt;p&gt;I wanted FeedFilters to let people authenticate without trusting me
with a password. Storing passwords is a category of responsibility
I&amp;rsquo;d rather avoid — the breach risk, the ongoing decisions
about hashing parameters and rate limits and lockouts. The usual
escape hatch is to lean on a third party like Google or Apple for
sign-in, but that brings its own baggage: a privacy story I don&amp;rsquo;t
want, an external dependency I don&amp;rsquo;t want, and account-linking edge
cases I don&amp;rsquo;t want. None of those felt like the right trade for a
small indie service.&lt;/p&gt;
&lt;p&gt;Passkeys are the modern alternative. The browser handles creation
and storage on the user&amp;rsquo;s behalf, and modern devices sync them
across the user&amp;rsquo;s other hardware via iCloud Keychain or 1Password
or whatever they&amp;rsquo;re using. The user gets strong, phishing-resistant
auth without having to remember anything; I get to not store
passwords. That trade seemed right.&lt;/p&gt;
&lt;p&gt;So passkeys are the default signup method on FeedFilters. There&amp;rsquo;s a
password path too — an opt-in choice on a separate page
— but the front door is a passkey. I made one design decision
that I expect some people to push back on: when you add a passkey to
your account, the default is for your password to be removed. The
reasoning is that a passkey is a step up in security, and leaving a
password sitting around as a secondary method undoes a lot of what
the passkey gave you. If you&amp;rsquo;ve decided you want the stronger thing,
I think it&amp;rsquo;s right to also retire the weaker thing, and that&amp;rsquo;s the
path the UI recommends. The recovery story for losing a passkey is
the same one you&amp;rsquo;d use for a forgotten password: a reset email.
That&amp;rsquo;s the underlying reason FeedFilters verifies email addresses at
signup — not for marketing reach, but so that the email
channel is trustworthy as a recovery path.&lt;/p&gt;
&lt;p&gt;That said, this started out as a hard rule rather than a default.
Passkey-add unconditionally cleared the password, with a warning.
I softened it later to a checkbox, on by default, that you can
uncheck if you&amp;rsquo;d rather keep both factors. The cliff turned out to
be harsher than necessary for users curious about passkeys but not
yet ready to commit; the new default still nudges toward the
stronger story, it just doesn&amp;rsquo;t force it.&lt;/p&gt;
&lt;p&gt;The implementation didn&amp;rsquo;t go entirely smoothly.&lt;/p&gt;
&lt;p&gt;The first thing that bit me was a WebAuthn detail I hadn&amp;rsquo;t expected
to think about. The &lt;code&gt;go-webauthn&lt;/code&gt; library validates a &amp;ldquo;backup
eligibility&amp;rdquo; flag on every assertion: the stored credential&amp;rsquo;s BE
flag has to match the one in the assertion. I&amp;rsquo;d shipped without
persisting that flag, so it defaulted to zero, while real synced
platform passkeys come back with BE set. Every login attempt from a
synced passkey failed with &lt;em&gt;&amp;ldquo;Backup Eligible flag inconsistency
detected.&amp;rdquo;&lt;/em&gt; The fix was a small migration and a few lines of code
— capture &lt;code&gt;backup_eligible&lt;/code&gt; and &lt;code&gt;backup_state&lt;/code&gt; at registration,
refresh &lt;code&gt;backup_state&lt;/code&gt; on each successful assertion — but
finding it took some time with the spec. WebAuthn is a careful
specification, which is right for what it&amp;rsquo;s protecting, but
&amp;ldquo;careful&amp;rdquo; sometimes means &amp;ldquo;you have to handle subtleties you
wouldn&amp;rsquo;t have thought of.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The first UX iteration came the same evening. The day-two build had
passkeys and passwords sitting side by side on the signup form
— effectively equal options — and treating them that
way undersold what passkeys are for. So I made passkey the default
path: the signup page leads with a single passkey button, and
there&amp;rsquo;s a quieter link below for the people who&amp;rsquo;d rather use a
password, routed through a separate password signup page. The path
of least resistance should be the better path, and I wanted that to
be the case here.&lt;/p&gt;
&lt;p&gt;The second iteration came a couple of weeks later, and was a fix
for something I hadn&amp;rsquo;t realized I&amp;rsquo;d broken. The login page was
firing a modal &lt;code&gt;navigator.credentials.get&lt;/code&gt; on page load for any
browser that supported &lt;code&gt;PublicKeyCredential&lt;/code&gt;. The intent was
friendly: if you have a passkey for FeedFilters, just pick it from
the system prompt and you&amp;rsquo;re in. The reality was hostile: on every
browser that &lt;em&gt;didn&amp;rsquo;t&lt;/em&gt; have a passkey for the site, the WebAuthn
modal would intercept whatever the user tried to do next, including
their click into the password field. The modal would dismiss, the
click would have been absorbed, and the user wouldn&amp;rsquo;t notice
anything had gone wrong other than the login page seeming weirdly
unresponsive.&lt;/p&gt;
&lt;p&gt;The fix was to switch from auto-invoke to conditional UI, which is
exactly what the API was designed for. With &lt;code&gt;mediation: &apos;conditional&apos;&lt;/code&gt;
the browser doesn&amp;rsquo;t pop a modal at all; it surfaces available
passkeys inline as autofill suggestions in the email field,
alongside any saved emails. Users with a passkey see it offered in
the natural place. Users without one are completely undisturbed.
This is what the spec recommends, and I should have used it from
the start.&lt;/p&gt;
&lt;p&gt;Passkeys are the right default for a service like this, but the
path to &amp;ldquo;passkeys are pleasant&amp;rdquo; is paved with details that aren&amp;rsquo;t
obvious until you trip over them. Two iterations in, I think the
login and signup experiences are finally where I want them. I&amp;rsquo;ll be
curious to see what the third one needs.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>An MVP in a day</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/04/20/mvp-in-a-day"/>
    <id>tag:test.feedfilters.net,2026-04-20:/blog/mvp-in-a-day</id>
    <published>2026-04-20T12:00:00Z</published>
    <updated>2026-04-20T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>What it took to get the first usable version of FeedFilters, and what shipped along the way.</summary>
    <content type="html">&lt;p&gt;What &amp;ldquo;MVP&amp;rdquo; meant for FeedFilters was small but specific. A user has
to be able to sign up, add a feed, write a filter against it, and
subscribe to the filtered output in their RSS reader. Without all
four of those, nothing about the concept is testable. Everything
else — polish, convenience features, admin tooling —
could come later.&lt;/p&gt;
&lt;p&gt;Day one didn&amp;rsquo;t get me there. Day one was the plumbing: a bootstrap
script for the Linode host, a Docker setup that I&amp;rsquo;d flipped from
Docker Desktop to OrbStack on my Mac, a Caddy reverse proxy
configured to host multiple sites on the same box, and a GitHub
Actions deploy workflow that pushed images to GHCR and pulled them
on the host. None of that is the product. But all of it had to
exist before the product could.&lt;/p&gt;
&lt;p&gt;Day two is when the actual app showed up. In a single push I had
schema and migrations for both databases, runtime config loading,
full authentication (signup, email verification, login, sessions,
logout), account settings (change email, change password, passkey
management), feed add/edit/list, the filter engine itself with unit
tests, the feed output endpoint with source caching, feed icon
fetching, OPML import and export, an admin UI, SMTP wiring, folder
support, and a first pass at rate limiting, error states, empty
states, dark mode, and mobile layout.&lt;/p&gt;
&lt;p&gt;Some of those decisions were obvious. A feed-filtering service has
to fetch, parse, filter, and serve feeds, so the engine and the
output endpoint were never in question. Others were less obvious. I
went with passkeys &lt;em&gt;and&lt;/em&gt; passwords from day one rather than adding
passkeys later, which meant taking on the WebAuthn complexity up
front (and getting bitten by a BE/BS-flag bug a few hours in). I
split the data into two SQLite files, one for the application and
one for metrics, to keep the operational schema from contending with
the user-facing one. I shipped OPML import and export immediately
because the people I&amp;rsquo;d want to use FeedFilters already have
subscriptions sitting in some other reader, and their first
interaction shouldn&amp;rsquo;t be retyping all of them. Dark mode and a
mobile layout went in for the same reason — my own bar for
&amp;ldquo;would I show this to a friend&amp;rdquo; included those.&lt;/p&gt;
&lt;p&gt;I am still a little stunned by that list.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s not that any individual piece is hard. Auth is auth, CRUD is
CRUD, parsing RSS is parsing RSS. But every one of those is its own
little ecosystem of decisions: schema choices, edge cases, error
paths, UI states, security details. Doing them all in a day, well
enough that the concept of FeedFilters was actually testable
end-to-end, would have been weeks of evening-and-weekend work for me
alone. It wasn&amp;rsquo;t, because it wasn&amp;rsquo;t me alone.&lt;/p&gt;
&lt;p&gt;The implementation experience was different from anything I&amp;rsquo;d done
before. I spent most of that day at a level above the code —
deciding what should exist, how the pieces should fit together, what
the user flows should look like — and Claude did the typing.
That isn&amp;rsquo;t to say I wasn&amp;rsquo;t reviewing what got produced. I reviewed
all of it (sometimes by reading the code, sometimes by clicking
through the running app), pushed back when I disagreed, and made
the calls about which approaches to take. But the friction of
producing code wasn&amp;rsquo;t the thing soaking up my attention. The design
of the system was. There&amp;rsquo;s a real difference between a day where
that&amp;rsquo;s what you&amp;rsquo;re doing and a day where you&amp;rsquo;re also fighting the
language and the libraries to get the design typed out.&lt;/p&gt;
&lt;p&gt;Working this way turns out to be a lot like writing. The hard part
isn&amp;rsquo;t the first draft, it&amp;rsquo;s the revision. Trying to produce
something polished on the first pass is slower than getting a rough
version working and then sanding it down.&lt;/p&gt;
&lt;p&gt;The MVP wasn&amp;rsquo;t pretty everywhere — some of those subsystems
got real time spent on them later, and the proper design pass came
much later still. But the product worked. I could create an account,
add a feed, filter it, subscribe to the output in my feed reader,
and watch the filtered version come through. That&amp;rsquo;s the moment the
project went from idea to thing.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Boring on purpose</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/04/19/boring-on-purpose"/>
    <id>tag:test.feedfilters.net,2026-04-19:/blog/boring-on-purpose</id>
    <published>2026-04-19T12:00:00Z</published>
    <updated>2026-04-19T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>Why FeedFilters runs on Go, SQLite, server-rendered HTML, and a single Docker container — and the experiment of picking a language I barely know.</summary>
    <content type="html">&lt;p&gt;The stack I picked for FeedFilters is deliberately boring. A single
Go binary, embedded SQLite, server-rendered HTML, vanilla CSS and JS,
packed into a Docker image the size of the binary itself. One process
to run, one file to back up, no Redis, no Postgres, no React, no
message broker, no build step. For a solo project, that shape matters
to me more than almost anything else I could optimize for.&lt;/p&gt;
&lt;p&gt;Operationally, I want as little surface area as possible. Every
moving part is something I&amp;rsquo;d have to think about when it breaks at
11pm, and I don&amp;rsquo;t have a team to share that with. The fewer pieces
now, the fewer pages later.&lt;/p&gt;
&lt;p&gt;Go appealed to me on a few axes. It&amp;rsquo;s fast enough for anything I&amp;rsquo;m
likely to throw at it. The standard library is honestly large enough
to build a real app with, which I find rare among modern languages.
Concurrency is a first-class part of the language rather than a
third-party concern. And the build pipeline is dramatically simpler
than what I&amp;rsquo;m used to — one binary, no runtime to install on
the host, no dependency hell. Code written today will, by Go&amp;rsquo;s
explicit promise, still compile in ten years.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a wrinkle I should be honest about: aside from going through
the Go tutorial many, many years ago, I haven&amp;rsquo;t actually written any
Go. So picking it for this project is itself part of the experiment
— can I lean on Claude to do the Go writing while I drive the
shape of the program at a higher level? That&amp;rsquo;s the bet, and the
answer so far has been an emphatic yes, but I&amp;rsquo;ll have more to say
about that once I&amp;rsquo;ve got more miles on it.&lt;/p&gt;
&lt;p&gt;SQLite is the part of the stack I do know. I&amp;rsquo;ve been using it on and
off for years for small projects and side tools, and the pitch hasn&amp;rsquo;t
changed: it&amp;rsquo;s a file. There&amp;rsquo;s no separate database process to run,
no network hop on every query, and backups are as simple as copying
that file somewhere safe. It&amp;rsquo;s plenty fast for a read-heavy workload
like this one, especially with the indexes set up well. And, like
Go, it&amp;rsquo;s something I can reasonably expect to still be working in a
decade.&lt;/p&gt;
&lt;p&gt;The web side is server-rendered HTML, a single hand-written CSS file,
and a small amount of plain JavaScript. No React, no Vue, no Svelte,
no build step for the frontend at all. Templates render on the server,
the browser gets HTML, and a few lines of vanilla JS handle the bits
that genuinely need it. This is also a deliberate choice. SPA
frameworks are excellent at certain kinds of problems, and FeedFilters
isn&amp;rsquo;t one of them. Pages submit forms and navigate; there&amp;rsquo;s no
real-time anything. Skipping the framework saves me a build pipeline,
a &lt;code&gt;node_modules&lt;/code&gt; to keep current, a separate frontend deploy story,
and a category of bugs that comes from running the same logic on two
sides of the wire.&lt;/p&gt;
&lt;p&gt;Deployment continues the same instinct. The Go binary lives inside a
Docker container built from &lt;code&gt;FROM scratch&lt;/code&gt; — the image contains
the binary, a CA bundle for outbound HTTPS, and literally nothing
else. No shell, no package manager, no init system. If the binary
crashes, the container exits, and &lt;code&gt;restart: unless-stopped&lt;/code&gt; brings it
back. That&amp;rsquo;s the entire supervision story. The same image runs
locally, in CI, and in production, with a single docker-compose file
describing the production shape and a single host running it. No
Kubernetes, no orchestration layer, no service mesh. When something
goes wrong I have one place to look.&lt;/p&gt;
&lt;p&gt;Every choice in this stack comes back to the same instinct: pick the
things I won&amp;rsquo;t have to keep up with. The point isn&amp;rsquo;t moving faster
while I build it. It&amp;rsquo;s not having to keep thinking about this
service once I&amp;rsquo;m done working on it.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The Xcode on-ramp</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/04/18/xcode-on-ramp"/>
    <id>tag:test.feedfilters.net,2026-04-18:/blog/xcode-on-ramp</id>
    <published>2026-04-18T12:00:00Z</published>
    <updated>2026-04-18T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>How a coding-agent feature in Xcode got me into AI-assisted development, and what I&apos;m watching for on this project.</summary>
    <content type="html">&lt;p&gt;I&amp;rsquo;m a relative newcomer to using generative AI for software
development. I came to it skeptical. I&amp;rsquo;d seen and heard plenty of
cases of AI getting things laughably wrong, and beyond that, I&amp;rsquo;ve
been a bit unsettled about what generative AI is doing to the
software industry — and to the craft of building software. Not
the most enthusiastic disposition to bring to a new tool.&lt;/p&gt;
&lt;p&gt;What got past the skepticism, of all things, was Xcode. When Apple
wired coding agents directly into the editor, I tried it because the
friction was zero — it was just &lt;em&gt;there&lt;/em&gt;, in the tool I was
already using. And it turned out to be a transformative experience in
a way I genuinely hadn&amp;rsquo;t expected. Not magic, but a real shift in
what felt possible inside a normal day&amp;rsquo;s work.&lt;/p&gt;
&lt;p&gt;The broader unease didn&amp;rsquo;t go anywhere, though. The industry I&amp;rsquo;ve
spent years inside is changing shape under me, and I&amp;rsquo;m honestly not
sure the version of it that&amp;rsquo;s coming has a place for the way I like
to work. Sitting that out isn&amp;rsquo;t really an option, but pretending the
question is settled because the tool happens to be good doesn&amp;rsquo;t feel
right either. So I&amp;rsquo;m using it, and trying to pay attention to both
things at once.&lt;/p&gt;
&lt;p&gt;When I decided to build FeedFilters — the story of why is in
&lt;a href=&quot;/blog/2026/04/17/the-concept&quot;&gt;the previous post&lt;/a&gt; — I wanted to
see how that experience held up outside Xcode. Xcode is a great
environment for Mac and iOS apps, but FeedFilters is a web app, and
web apps come with a lot of concerns native apps don&amp;rsquo;t have to think
about. I also wanted to see what working with Claude Code directly
was like, not just the version Apple has integrated into Xcode.&lt;/p&gt;
&lt;p&gt;So I&amp;rsquo;m using &lt;a href=&quot;https://claude.com/claude-code&quot;&gt;Claude Code&lt;/a&gt;, via the
Code section of the Claude app. The early going has been promising
enough that I&amp;rsquo;ve stuck with it for the whole build. Going in, the
things I&amp;rsquo;m curious about are pretty concrete:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How well does Claude do across genuinely different layers of a
project — backend, frontend, deployment, design — rather
than just within one of them?&lt;/li&gt;
&lt;li&gt;How does the velocity hold up over weeks of work, not just an
afternoon?&lt;/li&gt;
&lt;li&gt;Where does the quality fall off, and what kind of review cadence does
that suggest?&lt;/li&gt;
&lt;li&gt;What can I let it run with, and what do I need to stay close to?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I&amp;rsquo;ll write honestly about what I find once I have enough miles on the
tool to be fair to it. For now this is just the setup: the &lt;em&gt;why&lt;/em&gt; of
choosing Claude, and the things I&amp;rsquo;m planning to pay attention to. The
bigger questions — what the work and the community look like a
few years from now, and whether there&amp;rsquo;s still a place for me in them
— aren&amp;rsquo;t going to get settled by one project. But Claude can help me
take a personal project way further than I could on my own.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>An old idea, a new tool</title>
    <link rel="alternate" type="text/html" href="https://test.feedfilters.net/blog/2026/04/17/the-concept"/>
    <id>tag:test.feedfilters.net,2026-04-17:/blog/the-concept</id>
    <published>2026-04-17T12:00:00Z</published>
    <updated>2026-04-17T12:00:00Z</updated>
    <author>
      <name>Kyle Cronin</name>
    </author>
    <summary>Why FeedFilters now, after sitting on the domain for fourteen years.</summary>
    <content type="html">&lt;p&gt;I love the idea of RSS. That hasn&amp;rsquo;t really changed in twenty years. What
changes is whether I&amp;rsquo;m actually keeping up with it, and the pattern is
always the same: I subscribe to a healthy mix of feeds, read happily
for a few months, then watch the high-volume sources start to dominate.
Most days the backlog is manageable. Some days it isn&amp;rsquo;t, and the act of
scanning headlines just to dismiss them is its own small drain.
Eventually I drift away, the unread count climbs into four digits, and
a year later I sheepishly wipe the slate and start over.&lt;/p&gt;
&lt;p&gt;The fix has always seemed obvious to me: filter the firehose. Some
sources post too much, and I only want a slice. I don&amp;rsquo;t want to
unsubscribe — I want to subscribe to the parts I care about. A
keyword include, an exclude, an author filter. That&amp;rsquo;s the whole
feature.&lt;/p&gt;
&lt;p&gt;I had this idea in 2012. I registered feedfilters.com in September of
that year, fully intending to build a simple web service around it,
and then&amp;hellip; did nothing. The domain sat in my registrar account for
fourteen years, renewing quietly, while I read and stopped reading and
read and stopped reading. I&amp;rsquo;d half-forgotten I owned it.&lt;/p&gt;
&lt;p&gt;What jogged me out of that loop was mundane. I was migrating my domains
from Namecheap to Porkbun, going through the list one by one, and there
it was: feedfilters.com. The same idea I&amp;rsquo;d had in 2012, still unbuilt,
and as far as I could tell still not really solved by anyone else. So
this time I decided to actually do it.&lt;/p&gt;
&lt;p&gt;The other thing that changed is the tooling. I&amp;rsquo;ve been using &lt;a href=&quot;https://claude.com/claude-code&quot;&gt;Claude
Code&lt;/a&gt; heavily, and a project I&amp;rsquo;d never
quite felt I had the time to do &lt;em&gt;right&lt;/em&gt; suddenly felt within reach.
What you&amp;rsquo;re reading runs on the result. The pace has frankly surprised
me — more working software, in less time, with more care taken on
the design and infrastructure than a &amp;ldquo;side project&amp;rdquo; usually gets.&lt;/p&gt;
&lt;p&gt;So that&amp;rsquo;s the concept, and that&amp;rsquo;s why now. Building this has been a
genuinely interesting process, and I wanted somewhere to write down
some of the design decisions and experiences along the way. That&amp;rsquo;s
what this blog is for.&lt;/p&gt;
</content>
  </entry>
</feed>
