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

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.

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