FeedFilters
Pricing Log in Sign up

Blog

  • All posts
  • Subscribe (Atom)

Recent

  • Where the cookie boundary didn't May 12, 2026
  • A CDN for most of it May 11, 2026
  • Done, not abandoned May 8, 2026
  • There's no catch May 7, 2026
  • Sixteen mockups May 6, 2026
  • From scripts to infrastructure May 5, 2026
  • Doing mail myself May 4, 2026
  • Easier now than later May 2, 2026

Archive

  • 2026 15 posts

Blog

Where the cookie boundary didn't

May 12, 2026 · Kyle Cronin

Yesterday’s post included this sentence about how Cloudflare was supposed to keep authenticated HTML out of the edge cache:

FeedFilters’ authenticated routes already carry Set-Cookie — gorilla/csrf sets one on every response it sees — so the cookie boundary already does most of the work.

That sentence is wrong in three different ways, none of which I noticed until prod was already serving cached pages across users.

The symptom

I was editing a feed in the app — flipping the filter mode, adding a tag — and the changes weren’t showing up in the list. The CDN was the most recent thing I’d changed, so I curled the page with my session cookies and looked at the response headers. cf-cache-status: HIT, non-zero age:. Cloudflare had cached the page and was handing the stale copy back to my browser.

Worse: the cached page contained one user’s feed list. Anyone else who hit / while that entry was still alive at the edge could see it instead of their own. I don’t have evidence anyone actually got someone else’s data, but the conditions for it to happen were there.

Three things I had wrong

gorilla/csrf does not set a cookie on every response. It only writes Set-Cookie when generating a new token — when the existing _gorilla_csrf cookie is missing or fails HMAC. Once a visitor has a valid one, every subsequent response leaves origin with no Set-Cookie at all. Most authenticated traffic falls on the no-Set-Cookie side of that.

Cloudflare’s cache rule overrode the default Set-Cookie bypass. Cloudflare normally refuses to cache responses with Set-Cookie. The cache rule I wrote yesterday sets cache = true explicitly, which is the documented way to force-cache responses the default would skip. So even on the first hit, when there was a Set-Cookie, the rule was force-caching it anyway.

Cloudflare’s cache key doesn’t honor Vary: Cookie. gorilla/csrf adds Vary: Cookie to every response in its group, which is the spec-correct way to tell shared caches that a response varies per cookie. Cloudflare’s default cache key is scheme + host + path + query, and it does not extend that with Vary: Cookie. The header passes through to the browser but, from Cloudflare’s perspective, is decoration.

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’d assumed.

The fix

Authenticated routes default to Cache-Control: private, no-store via a small middleware that runs first in the CSRF group. That’s the explicit “don’t cache” signal the gorilla/csrf cookie was supposed to be and isn’t. Cloudflare’s respect_origin mode honours no-store, so the edge stops holding onto authenticated HTML regardless of what other headers the response carries.

The anonymous lander came out of the CSRF group entirely. Yesterday’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. / now lives outside the CSRF group, with handleRoot either rendering the lander for anonymous visitors or 303-redirecting signed-in users to a new /feeds route inside the auth group. The anonymous response goes out with no Set-Cookie and no Vary: Cookie, and Cloudflare actually caches it.

The Cloudflare cache rule grew a session-cookie guard so it only matches requests without a session= cookie. Belt and suspenders with the app’s no-store default: the rule’s cache = true force-caching no longer applies to anything authenticated, regardless of what the origin happens to send.

A regression test in cache_control_test.go asserts the lander response has no Set-Cookie and no Vary: Cookie, so the boundary is something written down rather than emergent.

What I’d take from this

Putting a CDN in front of a dynamic application isn’t a one-line config decision. It’s a contract between three independent moving parts — the framework’s response-header behaviour, the CDN’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.

The concrete change for me is small: any caching-adjacent edit now gets a curl through the CDN before I call it done. Looking at cf-cache-status on a few representative paths takes about a minute, and it would have caught this the day it shipped.

A CDN for most of it

May 11, 2026 · Kyle Cronin

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’s the headline. The bundle wrapped around that headline is what makes the actual decision interesting.

The full set of things you get from turning Cloudflare on for a hostname looks more like:

  • Cache hits served from the edge, when the response is cacheable and the cache has a fresh copy.
  • TLS terminated at the edge, close to the client, regardless of whether the response is cacheable.
  • DDoS shielding and bot/abuse filtering applied before any request reaches origin.
  • The origin IP hidden behind Cloudflare’s anycast network.
  • Free, with no contract.

And the costs, in roughly the same shape:

  • An opaque hop in the middle of every request path. When something misbehaves, “is it me or the CDN” is a new question to triage every time.
  • 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.
  • Headers you didn’t have before show up (CF-Connecting-IP, CF-Ray), and code that expected r.RemoteAddr to be the client IP starts seeing a Cloudflare edge IP instead.
  • Authenticated or per-user responses don’t cache safely. If they do end up cached, you’ve leaked one user’s view to another.

The cache itself, which is what most people mean when they say “a CDN,” 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.

Where it fits for FeedFilters

The first cut at this looked like a clean split between the two public hostnames. The feeds host 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 web app at the apex looked like the opposite: per-user pages rendered against a session cookie, no two responses alike, nothing to cache safely.

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 / — those don’t depend on who’s asking. The same response would go to every visitor, and there’s no reason an edge can’t hand it back without checking with origin every time. The real split inside the web app isn’t between hostnames; it’s between anonymous and authenticated responses, and the boundary runs through the router rather than through the DNS.

Once that’s the line, Cloudflare’s proxy in front of the apex doesn’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 Set-Cookie is treated as per-user and skipped, and anything with Cache-Control: public is considered eligible. FeedFilters’ authenticated routes already carry Set-Cookie — 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’t need it.

So: most of FeedFilters goes orange. The apex, www, and the feeds host are all proxied; the cookie boundary inside the app decides what the edge caches and what it doesn’t. The only things that stay grey are operational paths that don’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 (mail.feedfilters.net, host.feedfilters.net) so the SMTP and SSH listeners on the box keep working through DNS that bypasses Cloudflare entirely.

In code, not in the dashboard

By the time the deployment work shipped a few days ago, nothing about the live system’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 tofu apply, swappable later without anyone trying to remember which UI a setting had been clicked in.

The pieces this turned into, all in the same tofu apply:

  • DNS. feedfilters.net, www.feedfilters.net, and feeds.feedfilters.net orange-clouded. mail.feedfilters.net and host.feedfilters.net (the new MX target and the new SSH target) grey-clouded. The MX record repointed from the apex to mail.feedfilters.net. SPF stayed correct because it uses the mx mechanism plus an explicit ip4: literal, both of which still resolve to the same origin IP.
  • Zone settings. SSL mode to Full (Strict) so Cloudflare verifies the Let’s Encrypt certificate Caddy is already serving; Always Use HTTPS on so the edge handles the http-to-https redirect rather than the origin.
  • Cache rule. Scoped to the three orange-clouded hostnames, set to respect the origin’s Cache-Control headers. Without it, Cloudflare’s default behaviour caches only by file extension, which doesn’t match either the extensionless /{id} feed URLs or the app’s HTML routes.
  • Caddy ACME pin. A one-line label in FeedFilters’ compose telling Caddy to use the HTTP-01 ACME challenge rather than TLS-ALPN-01. TLS-ALPN-01 can’t reach origin once Cloudflare terminates TLS at the edge; HTTP-01 keeps working because Cloudflare passes /.well-known/acme-challenge/* straight through to origin.
  • App-side routing. The truly public routes (/about, /help, /privacy, all of /blog/*) moved into a chi router group that doesn’t include the gorilla/csrf middleware. With no Set-Cookie on the response, Cloudflare’s edge actually caches it. A small publicCacheMiddleware sets Cache-Control: public, max-age=300 and clears the signed-in user from the request context so the layout’s auth-aware chrome doesn’t differ across visitors and break the cache.

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’s proxy doesn’t forward port 22, so the first deploy after the cutover died with Network is unreachable. The fix was the grey-clouded host.feedfilters.net record above, a small extension of the host-keys capture script to emit DEPLOY_KNOWN_HOSTS entries against the new name, and a one-line workflow change to scp/ssh through the new hostname.

What I noticed

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 Set-Cookie, and Set-Cookie is the signal every well-behaved cache uses to mean “this is per-user, don’t store.” The work was less about adding caching logic than about removing CSRF protection from routes that didn’t need it in the first place — the about, help, privacy, and blog pages. The right behaviour fell out almost by accident.

The other thing was TLS. Caddy is still doing what Caddy does for every hostname that reaches it: serving a Let’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’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’s not the trade I want, and the redundancy is cheap.

Most of the time went on the deciding part, not the doing part, which I’m taking as a good sign about where the infrastructure is now.

Done, not abandoned

May 8, 2026 · Kyle Cronin

When I’m checking out a new app, one of the first things I look at is the last updated date in the App Store. A recent date is a green flag. Last updated 2 years ago is a yellow one. Has the developer wandered off? Is this thing dead?

That instinct isn’t crazy. Plenty of software does just stop because the person making it loses interest or runs out of time, and a stale “last updated” line is often the first sign. But it isn’t the only reason a piece of software might go quiet. Sometimes the developer hasn’t gone anywhere. They’ve just decided the thing is done.

I want to be careful here. I’m not arguing that “done” software is better than software that keeps changing. There’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’t an option for the team running them. None of this is what I want to push back on.

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 not getting constant updates starts to feel like it’s failing some basic test of liveness. No new release in a year? Probably abandoned. That shorthand catches a lot of true positives. It also catches things that aren’t broken at all.

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’s fine, it suits what they’re doing. But there are also classic games that shipped, got a handful of patches, and never got updated again, and they’re just as fun to play now as they were the year they came out. Tetris didn’t need a roadmap. The absence of patches isn’t a defect; the game just works.

Software that does a defined job can be like that too. Not all of it — but more of it than the last updated date suggests. A static-site generator that takes markdown and emits HTML isn’t going to need quarterly redesigns to remain useful. A keyboard remapper that sits between a USB device and the OS doesn’t need a roadmap. A web service that filters RSS feeds will just quietly keep working. Software that’s bounded by a finite problem can be finished in a real sense. It doesn’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.

That’s where I’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’m also open to ideas from people who actually use it — the core is small and I’m sure there are good suggestions I haven’t thought of. But the whole concept only goes so far. There’s a horizon to this project, and once the work is up to that horizon, the work is done.

Part of the reason I’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’d like that to read as the state I was aiming for, not as a sign that I bailed. I’ll still be around if something breaks. Most of the time, though, nothing should need to. That’s what done is supposed to look like.

There's no catch

May 7, 2026 · Kyle Cronin

When I find a free service these days, my first instinct is to look for the catch. There’s almost always a catch. Free up to N items, then $9 a month. Free for personal use, then $19 for the “Pro” tier. Free during the trial, then it auto-bills. The catch is rarely a deal-breaker on its own — what’s exhausting is the discovery process. Every new tool gets a where’s-the-wall tax before I commit to learning it.

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.

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 what if a freemium tier with N feeds and Y rules per month. The plan, going all the way back, was always to put it out there for free.

The reason is simpler than I’d like it to be: I wanted this thing to exist, so I built it. Conceptually it isn’t hard. The model is RSS, and RSS isn’t really moving — the standard hasn’t meaningfully changed in two decades, and the filter primitive (include and exclude tags) is bounded by what’s useful, not by what the platform can support. Once the thing works, there isn’t a lot of feature pressure. A working FeedFilters in 2028 should look a lot like a working FeedFilters today. That’s a different shape than a SaaS that has to keep adding features to keep retaining customers; it’s closer to a utility that can sit in the background and quietly do its job.

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’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.

The numbers work, too. Hosting is $5 a month at Linode. The domain is $12.52 a year. I’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’t notice them. If it grows, I’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’s the bar — not make a living off this, not build a business, just keep the lights on long enough for the thing to be useful for a long time. If it turned out that fewer than one in a thousand are willing to do that for a service they actively use, I’d revisit. I hope it won’t come to that.

Underneath all of that is a smaller belief I’ve been circling for a while. Not everything has to be a product. Not everything has to make money. Sometimes it’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’t there because the software couldn’t do the thing — it’s there because someone decided that’s the right pain threshold to convert at. After enough of that, free in software stops meaning free and starts meaning you haven’t found the wall yet.

I’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’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’d quietly resent paying for. So I made it the kind of thing I wouldn’t quietly resent. You’re welcome to use it too, if you want to. For free, no catch.

Sixteen mockups

May 6, 2026 · Kyle Cronin

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’t gotten serious attention. So I sat down to do the design pass.

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.

I had a hope going in. I’d been impressed enough with what Claude could do across other parts of the project that I figured I’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’d get a wealth of creative options to react to, and I’d hone in from there.

That isn’t really what happened.

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’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 “how it works” diagram, a final call-to-action above the fold. And almost every one of them defaulted to a startupy register: pose the user’s problem, sell them on it for a paragraph, position FeedFilters as the solution.

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.

Zoomed in, here’s what one of them looked like:

Atlantic-blue mockup at readable size: kicker “A considered way to read the web,” a bold serif tagline “The reading you came for, cleanly delivered.”, a body paragraph framing the problem, a “Start filtering — it’s free” call-to-action, and a mock “Bon Appétit Daily” newsletter card to the right.

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’t a startup. It’s a personal project I’m making available because I think it’s useful, and because I want it to exist. The framing I needed wasn’t “here’s how we solve your pain” — it was “I made this thing; if you have the same RSS problem I have, maybe it’ll help you too.” 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.

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’t do. Same sources, less noise — that became the through-line. Audience: people who already know they have an RSS problem and don’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.

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.

The lander as it shipped: clean nav, a teal accent, the locked tagline “Same sources, less noise.”, a subhead, a sign-up button, and a before/after demo card showing a “National News” feed filtered by “exclude: politics, election, Congress”.

Walking the templates with Claude to surface where the new palette and button system didn’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.

Here’s the feeds list page, before the design pass:

The pre-redesign feeds list: a plain text top-bar (Home / Admin / Debug / Account / Log out), an unstyled “Your feeds” 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 “No filter — passes everything through” subtext).

And after:

The redesigned feeds list: brand logo + FeedFilters in the topbar, the same content wrapped in a page-card, primary teal “Add Feed” button leading the toolbar, teal-accented Global filters card, polished folder and feed cards with hover-icon affordances, footer with About / Help / Blog / Privacy & Terms.

One small detail in the lander captured the dynamic well. The use-cases section is built around a few short phrases in the shape “X, without the Y” — national news, without the politics; tech blogs, without the AI hype; that kind of thing. Claude’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’t do anything — they just sat there next to the rest of the body copy.

The first take: each “X, without the Y” 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.

I asked whether they could appear inside cloud-shape bubbles, the way thought bubbles do in a comic, breaking up the page’s rhythm and letting the phrases read as ideas instead of paragraphs. The result was a noticeable improvement. Same sentences, totally different visual impact.

The redirected version: the same phrases set in rounded white pill-shaped bubbles, scattered across two columns with subtle shadows, and the matching “Your feeds, without the noise.” line below as the conclusion.

I don’t think I’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.

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’s editorial instincts kept pulling toward the trope I didn’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.

I don’t think this is a permanent ceiling on what AI can do for design. It’s possible another model is better at this kind of work, or that the same model with better prompting would get further. It’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’s coherent, it’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’d reach for a person rather than a prompt.

From scripts to infrastructure

May 5, 2026 · Kyle Cronin

By the time the mail rebuild was finished, the way I was deploying FeedFilters had grown into something I wasn’t proud of. It started simply enough — a deploy.sh that pushed a fresh image to the production host, restarted the container, and called it done. Then a bootstrap.sh for getting a brand-new host configured: Docker, a deploy user, the shared Caddy network, the firewall. Then a provision.sh for the things bootstrap.sh shouldn’t do at the same time. Then sysctl tuning landed in /etc/sysctl.d for the load-test work. Then every time the app gained a new environment variable, the production .env had to be hand-edited to match the new shape.

Each piece was small and made sense at the time. Together, they’d become fragile. If bootstrap.sh wasn’t carefully idempotent, re-running it on a partially-provisioned host could leave production in a worse state than it found it. Hand-editing .env 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’d never been comfortable with — a mistake during build now meant a sick production host.

What I wanted

What I wanted was for the box’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’d forget a step on the next deploy and find out about it when something broke.

Options considered

The two obvious shapes for this in 2026 are still Kubernetes and a NixOS-style declarative-OS approach. I looked at both.

Kubernetes is the obvious overkill answer. It’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’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’t yet sure I needed. For one box running a handful of personal apps, the tax wasn’t worth it.

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’t available through Homebrew — I hit the brakes. That was the signal that I was getting in for more than I’d bargained for, on a project where the goal was just “make the box predictable.”

The shape I settled on

The system I ended up with has three pieces, each handling a different layer of what’s running.

OpenTofu 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’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.

Ansible owns the host side: Debian packages, the deploy user, the shared caddy 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’s in to whatever the playbook describes, without my having to remember which steps I already ran.

GitHub Actions 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 “pull the new image and restart the container” step. The build never touches the production host. The deploy is a single idempotent step at the end of a CI run.

The choice between these tiers wasn’t obvious up front, and I’d be lying if I said I’d planned to settle on this exact combination. But each tool turned out to fit its layer well enough that I haven’t been tempted to swap any of them out.

The mail dovetail

The mail rebuild made all of this a much easier trade to justify. Sending mail reliably required DNS records I hadn’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’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’t realistic for long.

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’d been quietly avoiding became a small edit instead of a half-day project.

The tiered approach

The three layers turn out to map neatly onto the “one box, many apps” 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.

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’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.

Looking back

The fragility arc that motivated this is gone. I haven’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’s filesystem. And the cloud, the host, and the app each have a single source of truth that lives somewhere other than the box.

The other thing I’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’t tested that end-to-end, but every piece of it has been individually verified during the build, and the gap between “I haven’t tested it” and “I’m confident it works” is much smaller now than it was a week ago.

Doing mail myself

May 4, 2026 · Kyle Cronin

When I shipped the day-two MVP, the auth flows talked to a Postfix instance bundled in the same container. I hadn’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.

When I did come back, the question I ended up with wasn’t “how should I configure Postfix” or “which mail-as-a-service should I sign up for.” It was: how complicated is this actually?

Why

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’re well-engineered. They handle deliverability, DKIM, suppression lists, all the stuff people who actually do email a lot have to think about.

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’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’s a few transactional templates fired at the user’s address when they ask for them.

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’t actually have. SMTP is forty years old. It’s well-specified, well-documented, and Go’s standard library can speak it out of the box. I figured I’d see how far I could get on my own.

What “doing it myself” actually means

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’s domain and the relay tells you about it.

Direct-to-MX means the app does the middle steps itself. Look up the recipient’s MX, open a connection to it, send the message, handle the response. There’s no separate relay process; the Go binary is the mail sender. For outbound-only transactional traffic, that’s a remarkably small amount of code, and Go’s net/smtp and net packages give you the primitives.

Mine ended up structured as an outbox queue with a worker. Calls into the package don’t send synchronously; they enqueue a row in email_outbox 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 bounced 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’t lose messages on the kinds of network blips that make SMTP miserable.

DKIM signing

The flag that turned this from “works on test addresses” into “actually deliverable” 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 <selector>._domainkey.<sender-domain>. Combined with SPF (which says “this IP is allowed to send for this domain”) and DMARC (which tells receivers what to do when SPF or DKIM fail), it’s the way mail in 2026 establishes that it isn’t forgery.

In code it’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.

In operations it’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.

Catching async bounces

Direct-to-MX has one major gap that a relay would normally cover. Sometimes the recipient’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’s domain. If you’re running Postfix, Postfix accepts the DSN, parses it, and writes the result to its logs. The standard advice for “track bounces” is “tail those logs.”

That advice didn’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.

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 (bounces@feedfilters.net). When a DSN arrives, the listener parses it, looks up the original outbox row by the bounce token embedded in the alias’s local part, and updates the row to bounced 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.

The DNS side is one MX record pointing at the host. The container side is exposing port 25 with cap_add: NET_BIND_SERVICE so the process can bind a privileged port. That’s the entire infrastructure.

The outbox as the dashboard

Because everything runs through that one email_outbox table, the table effectively is the operational dashboard. The admin UI’s Email page is a render of recent rows: id, recipient, subject, current state (pending / delivered / bounced / failed), the SMTP transcript or bounce reason, and how many attempts it’s taken. You can click into any row and see exactly what happened and when. There’s also a small “send a test email” form at the top, which has been the single most useful piece of UI for verifying live deliverability after a DNS or DKIM change.

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’s now the first place I look when a user reports they didn’t get an email.

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’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.

Dropping Postfix

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’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.

Looking back

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.

That’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’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.

Easier now than later

May 2, 2026 · Kyle Cronin

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 previous post covers. But a separate batch of work landed in the same window that wasn’t really about capacity. It was about giving the service room to grow without painful migrations later.

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. “Now or never” is the honest reason these landed when they did.

The feeds subdomain

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 feedfilters.net and feed output at feeds.feedfilters.net. 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.

“Without breaking anyone” 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’s reader has to re-subscribe to the new URL. That’s the kind of operational debt I’d like to avoid.

Caddy as a shared reverse proxy

The deployment shape FeedFilters runs in is “one box, many apps.” 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, “I serve feedfilters.net, www.feedfilters.net, and feeds.feedfilters.net; here’s my upstream port.” Caddy reads the labels off the shared caddy docker network, sorts out TLS automatically, and dispatches.

The reason this matters for scale isn’t the load — FeedFilters fits on a single Linode Nanode comfortably. It’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.

Versioned static assets

In the same spirit, every URL emitted into a template that points into /static/ — 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.

Because URLs change on deploy and old ones simply stop being requested, the cache headers on /static/* 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.

Operational discipline

Two smaller pieces in the same vein, each cheap to do once and worthwhile permanently.

The compose file’s healthcheck now hits /healthz. Without it, a crashed app inside a multi-process container could read as “Up 11 hours” in docker compose ps because supervisord was happily keeping the postfix sidecar alive. The healthcheck makes the operational view honest.

Each successful deploy gets an annotated tag of the form deploy/<timestamp>-<sha>. 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. git tag -l 'deploy/*' is a workable audit trail.

The instinct underneath

Looking at the whole batch together, there’s a thread I want to call out. None of these are really about adding capacity. They’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’t need per-app reverse-proxy tweaks, a static-asset story that doesn’t need cache-bust commits, and a feeds origin that won’t break subscribers when the topology underneath it changes.

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’t require remembering anything. If I ever do need to scale FeedFilters up, I want the answer to be “give it better hardware,” not “spend a weekend unwinding decisions I made on day two.”

Pointing load at it

April 30, 2026 · Kyle Cronin

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’t expected, and most of those “somethings” turned into commits that made the service substantially better.

This post tries to cover the full arc.

The harness

The load is synthetic, but it’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.

The harness has three pieces. A mock upstream that serves deterministic RSS / Atom / JSON feeds, with knobs for body shape, delay, hang, errors, and gzip. A runner with a small web UI that drives the test and aggregates metrics. And the SUT — 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’t competing with the workload for the only CPU the production target has.

The runner has two modes. Classic is the textbook shape: fixed user count, fixed ramp, stop when p95 latency crosses a threshold. Adaptive 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’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.

OS limits, before anything else

The first thing the load test surfaced was that my host wasn’t configured for traffic. Two kernel sysctls turned out to matter right away.

net.core.somaxconn 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 ECONNREFUSED errors immediately.

net.netfilter.nf_conntrack_max defaults to about 65K, which the service blew through during outbound-fetch storms. The symptom was confusing: DNS lookups started failing with write: operation not permitted. That turned out to be conntrack refusing to allocate a slot for a new outbound flow. Setting it to 524288 fixed it.

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 somaxconn is per-namespace. The host setting doesn’t propagate.

The connection-pool cliff

Once the kernel was out of the way, the next thing the runner found was a ceiling that didn’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.

The cause was that I’d left sql.DB’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.

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’t block each other) while pinning a hard ceiling on the writer queue. Excess request rate above that queues in Go’s scheduler instead of in SQLite’s lock manager, which is dramatically cheaper. There’s no principled formula behind 25; it’s empirically derived from capacity sweeps on the 1 GB Nanode that’s the reference deployment. Bigger boxes can likely run higher, but no one’s swept that.

Profiling and the hot-spot day

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’d already wired in pprof for dev builds, so I pointed it at a sweep and looked at where the cycles were going.

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’t expected: re-parsing the same upstream XML on every request, and re-normalizing every item’s text on every filter pass. The output endpoint was, in effect, paying full parse-and-filter cost for every cache hit.

Several changes landed in a single day:

  • Cache the parsed feed itself. Alongside the disk-backed HTTP cache for upstream fetches, an in-memory byte-bounded LRU holds the parsed gofeed.Feed 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.
  • Cache normalized item text. 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.
  • In-memory cache for Store.GetByID. 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.
  • Slim the cache value type. The original FeedCache 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.

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.

An upstream cache

Around the same time, I replaced the day-2 source cache with a new disk-backed HTTP cache (internal/httpcache). 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’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.

Debouncing the timestamp writes

At sustained cache-hit load — around 200 requests per second — every /feed/{id} request was firing a synchronous UPDATE on feeds.last_successful_fetch_at. With the connection pool capped at 25 and SQLite’s single-writer model, every UPDATE serialized through one writer lock. The runner started seeing SQLITE_BUSY errors and Caddy began returning 502s as the in-process queue grew.

The fix was a coalescing flusher: a FetchRecorder accumulates “feed N was just fetched successfully” 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’s contribution drops to one map insert under a mutex.

Two seconds of staleness on last_successful_fetch_at is invisible to admins; the writer-contention cliff disappeared completely.

Load shedding: from binary to graded

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 + Retry-After when the system is under unsustainable pressure.

The first cut was binary. When MemAvailable (read from /proc/meminfo 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’t.

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 “off” cycle’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’t keep up with, and we cascaded.

The replacement is a continuous probability instead of a flag. Each request is shed with probability p, where p is a linear interpolation between two thresholds: a soft floor (where shedding starts at 0%) and a hard floor (where shedding reaches 100%). As MemAvailable 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.

CPU got the same treatment using load1 / numCPU as the signal, with its own soft and hard thresholds. The shedder reports which signal is dominating, so an operator can tell why the system is shedding rather than just that it is.

This was the single piece of work where I most clearly felt the difference between “code that compiles and runs” and “code that behaves well under pressure.” The first version did the second thing badly. Getting it right took thinking about feedback loops, not just thresholds.

Recovery without intervention

The point of all this is that the system has to handle being overloaded without me being there. If I’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.

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’t clearing, the queue eats more memory, and you’re in a spiral the shedder can’t get you out of fast enough. The whole game is to shed before swap starts, not after.

That puts a constraint on the shedder’s signal. MemAvailable is the right thing to read — it’s the kernel’s accounting of “how much memory can you get without paging.” But there’s a complication: Go’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 MemAvailable is sitting around 100 MiB — not because anything is wrong, but because that’s where the GC decides it should be.

The first multi-phase capacity sweep tripped 100% shed on every test within forty seconds for exactly that reason. The shedder was reading “memory pressure” off natural GC headroom. The fix wasn’t to make the shedder less aggressive; it was to give the GC a target. Setting GOMEMLIMIT to about 70% of the cgroup limit (550 MiB on a 768 MiB container) tells Go’s GC to keep the heap under that, leaving real margin in the cgroup for genuine spikes. With the heap bounded, MemAvailable in healthy state sits at 200–300 MiB, and a floor of 50 MiB becomes a meaningful “about to swap” margin instead of a false-positive trigger.

Combined with the graded shedder, this gives the box a recovery shape I’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’s no “panic mode” the system has to manually exit.

The container itself runs with memswap_limit equal to mem_limit, 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’s the belt to the shedder’s suspenders.

Race conditions

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’t shown up in the synthetic runs.

In the HTTP cache:

  • The leader of a coalesced fetch was protected against the evictor by an in-flight registration held by the consumer’s Fetch 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’s “open the cache file” path. Fix: the leader registers its own protection.
  • A handful of WordPress installs returned 304 Not Modified to plain GET requests with no If-* 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’t.
  • The evictor’s filepath.Walk callback was returning walkErr unconditionally. Any concurrent rm between readdir and the callback’s stat produced an ENOENT that aborted the entire eviction tick.
  • snapshot() and reset() on the cache stats actor used unbuffered channels. Calling either after Close deadlocked forever.

In SQLite-land, three call sites had a pattern that looked innocuous but turned out to be a landmine: a deferred BeginTx, a SELECT, and then an INSERT or UPDATE. SQLite returns SQLITE_BUSY immediately when a transaction tries to upgrade from SHARED to RESERVED while another writer is active, and — this is the cruel part — busy_timeout doesn’t retry transaction upgrades. The fetch-batch flusher I’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 (UPDATE...RETURNING with every gate in the WHERE) or a write-only transaction.

These weren’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.

The bottleneck

One thing the load test resolved that I hadn’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’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.

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’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’t serving every request anymore. That’s plenty of headroom for FeedFilters to spend a long time at one box.

What I came out with

A couple of weeks ago, FeedFilters was a working app I’d shipped. Now it’s a working app I have rough numbers for: capacity, where it breaks, what breaks first, what to do when it does. That’s a different kind of confidence.

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.

Knobs and graphs

April 22, 2026 · Kyle Cronin

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’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.

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.

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.

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’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 where 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.

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.

The Runtime config page is the other piece I’m pleased with. Most of the limits I’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’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.

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.

The database reset belongs in the same category. It’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.

The graphs, the live knobs, and the easy reset are what made load testing feasible. That’s the next post.

Passkeys, twice

April 21, 2026 · Kyle Cronin

I wanted FeedFilters to let people authenticate without trusting me with a password. Storing passwords is a category of responsibility I’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’t want, an external dependency I don’t want, and account-linking edge cases I don’t want. None of those felt like the right trade for a small indie service.

Passkeys are the modern alternative. The browser handles creation and storage on the user’s behalf, and modern devices sync them across the user’s other hardware via iCloud Keychain or 1Password or whatever they’re using. The user gets strong, phishing-resistant auth without having to remember anything; I get to not store passwords. That trade seemed right.

So passkeys are the default signup method on FeedFilters. There’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’ve decided you want the stronger thing, I think it’s right to also retire the weaker thing, and that’s the path the UI recommends. The recovery story for losing a passkey is the same one you’d use for a forgotten password: a reset email. That’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.

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’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’t force it.

The implementation didn’t go entirely smoothly.

The first thing that bit me was a WebAuthn detail I hadn’t expected to think about. The go-webauthn library validates a “backup eligibility” flag on every assertion: the stored credential’s BE flag has to match the one in the assertion. I’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 “Backup Eligible flag inconsistency detected.” The fix was a small migration and a few lines of code — capture backup_eligible and backup_state at registration, refresh backup_state on each successful assertion — but finding it took some time with the spec. WebAuthn is a careful specification, which is right for what it’s protecting, but “careful” sometimes means “you have to handle subtleties you wouldn’t have thought of.”

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’s a quieter link below for the people who’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.

The second iteration came a couple of weeks later, and was a fix for something I hadn’t realized I’d broken. The login page was firing a modal navigator.credentials.get on page load for any browser that supported PublicKeyCredential. The intent was friendly: if you have a passkey for FeedFilters, just pick it from the system prompt and you’re in. The reality was hostile: on every browser that didn’t 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’t notice anything had gone wrong other than the login page seeming weirdly unresponsive.

The fix was to switch from auto-invoke to conditional UI, which is exactly what the API was designed for. With mediation: 'conditional' the browser doesn’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.

Passkeys are the right default for a service like this, but the path to “passkeys are pleasant” is paved with details that aren’t obvious until you trip over them. Two iterations in, I think the login and signup experiences are finally where I want them. I’ll be curious to see what the third one needs.

An MVP in a day

April 20, 2026 · Kyle Cronin

What “MVP” 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.

Day one didn’t get me there. Day one was the plumbing: a bootstrap script for the Linode host, a Docker setup that I’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.

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.

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 and 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’d want to use FeedFilters already have subscriptions sitting in some other reader, and their first interaction shouldn’t be retyping all of them. Dark mode and a mobile layout went in for the same reason — my own bar for “would I show this to a friend” included those.

I am still a little stunned by that list.

It’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’t, because it wasn’t me alone.

The implementation experience was different from anything I’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’t to say I wasn’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’t the thing soaking up my attention. The design of the system was. There’s a real difference between a day where that’s what you’re doing and a day where you’re also fighting the language and the libraries to get the design typed out.

Working this way turns out to be a lot like writing. The hard part isn’t the first draft, it’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.

The MVP wasn’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’s the moment the project went from idea to thing.

Boring on purpose

April 19, 2026 · Kyle Cronin

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.

Operationally, I want as little surface area as possible. Every moving part is something I’d have to think about when it breaks at 11pm, and I don’t have a team to share that with. The fewer pieces now, the fewer pages later.

Go appealed to me on a few axes. It’s fast enough for anything I’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’m used to — one binary, no runtime to install on the host, no dependency hell. Code written today will, by Go’s explicit promise, still compile in ten years.

There’s a wrinkle I should be honest about: aside from going through the Go tutorial many, many years ago, I haven’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’s the bet, and the answer so far has been an emphatic yes, but I’ll have more to say about that once I’ve got more miles on it.

SQLite is the part of the stack I do know. I’ve been using it on and off for years for small projects and side tools, and the pitch hasn’t changed: it’s a file. There’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’s plenty fast for a read-heavy workload like this one, especially with the indexes set up well. And, like Go, it’s something I can reasonably expect to still be working in a decade.

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’t one of them. Pages submit forms and navigate; there’s no real-time anything. Skipping the framework saves me a build pipeline, a node_modules 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.

Deployment continues the same instinct. The Go binary lives inside a Docker container built from FROM scratch — 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 restart: unless-stopped brings it back. That’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.

Every choice in this stack comes back to the same instinct: pick the things I won’t have to keep up with. The point isn’t moving faster while I build it. It’s not having to keep thinking about this service once I’m done working on it.

The Xcode on-ramp

April 18, 2026 · Kyle Cronin

I’m a relative newcomer to using generative AI for software development. I came to it skeptical. I’d seen and heard plenty of cases of AI getting things laughably wrong, and beyond that, I’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.

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 there, in the tool I was already using. And it turned out to be a transformative experience in a way I genuinely hadn’t expected. Not magic, but a real shift in what felt possible inside a normal day’s work.

The broader unease didn’t go anywhere, though. The industry I’ve spent years inside is changing shape under me, and I’m honestly not sure the version of it that’s coming has a place for the way I like to work. Sitting that out isn’t really an option, but pretending the question is settled because the tool happens to be good doesn’t feel right either. So I’m using it, and trying to pay attention to both things at once.

When I decided to build FeedFilters — the story of why is in the previous post — 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’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.

So I’m using Claude Code, via the Code section of the Claude app. The early going has been promising enough that I’ve stuck with it for the whole build. Going in, the things I’m curious about are pretty concrete:

  • How well does Claude do across genuinely different layers of a project — backend, frontend, deployment, design — rather than just within one of them?
  • How does the velocity hold up over weeks of work, not just an afternoon?
  • Where does the quality fall off, and what kind of review cadence does that suggest?
  • What can I let it run with, and what do I need to stay close to?

I’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 why of choosing Claude, and the things I’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’s still a place for me in them — aren’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.

An old idea, a new tool

April 17, 2026 · Kyle Cronin

I love the idea of RSS. That hasn’t really changed in twenty years. What changes is whether I’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’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.

The fix has always seemed obvious to me: filter the firehose. Some sources post too much, and I only want a slice. I don’t want to unsubscribe — I want to subscribe to the parts I care about. A keyword include, an exclude, an author filter. That’s the whole feature.

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… 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’d half-forgotten I owned it.

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’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.

The other thing that changed is the tooling. I’ve been using Claude Code heavily, and a project I’d never quite felt I had the time to do right suddenly felt within reach. What you’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 “side project” usually gets.

So that’s the concept, and that’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’s what this blog is for.

About· Help· Blog· Privacy & Terms
FeedFilters by Flat Six Software · © 2026