Easier now than later
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.”