A CDN for most of it
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 expectedr.RemoteAddrto 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, andfeeds.feedfilters.netorange-clouded.mail.feedfilters.netandhost.feedfilters.net(the new MX target and the new SSH target) grey-clouded. The MX record repointed from the apex tomail.feedfilters.net. SPF stayed correct because it uses themxmechanism plus an explicitip4: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-Controlheaders. 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 noSet-Cookieon the response, Cloudflare’s edge actually caches it. A smallpublicCacheMiddlewaresetsCache-Control: public, max-age=300and 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.