Most mornings start the same way. I’m up at 0500; a habit forged in the military and reinforced by a family of farmers. I wish it wasn’t so, but I begin with the inbox: chats, email, then a run. The run used to be dead time for reading. Then I hacked my treadmill, and suddenly the treadmill desk became my RSS slot. Roughly 200 feeds daily, spanning geopolitics, tech, energy, and whatever else caught my interest over the years.
That’s a lot of surface area, and for a while the breaking news slice of it lived in dedicated apps on my phone. Every app is another credential, another push pipeline, another vendor deciding what constitutes “breaking”. I’ve been working the other direction: fewer apps, smaller security footprint, and infrastructure I already run myself.
The parts already on the bench
I run my fair share of self-hosted services, and the relevant bench for this project is:
- Miniflux holds the feeds, including major British, German, and international English-language outlets
- ntfy delivers the alert to my phone, subscribed to a single topic
- Karakeep catches anything worth more than a headline, filed for reading at my leisure
- cron runs the glue
The idea is simple enough to state in one sentence: new entries in a
breaking-news category get pushed to an ntfy topic I subscribe to. No app
install, no third-party push service, no algorithm deciding what I see.
The part Miniflux can’t do
Miniflux has rules, filters, and rewrite regex, and people reasonably assume one of those can implement “expire unread entries older than an hour”. It can’t. The rule engine evaluates at feed insertion time, when a brand new entry is, by definition, zero minutes old. There is no TTL feature, no “mark read after X” anywhere in the interface. It’s a long-standing feature request rather than a shipped capability.
So: script and cron. The expiry job queries the Miniflux API for unread
entries in the breaking-news category, filters by created_at against a
cutoff timestamp, and batch-marks anything stale as read. Runs every few
minutes. Two curl calls and a jq filter counterfeit the missing feature
convincingly, which is how most Miniflux gaps get filled. The API is
deliberately complete enough that absent UI features are a shell script
away.
The alert side is a sibling script with a state file holding a high-water entry ID. Each run fetches the newest entries, selects anything above the watermark and inside a five-minute freshness window, pushes it to ntfy with the headline as the notification title and the article URL as the click target, then advances the watermark. Entry IDs are monotonic, so the watermark survives clock skew and polling gaps better than timestamps alone.
read later]
Why not AI, or a webhook
The obvious alternatives fell away one by one.
A webhook would push at feed-refresh time rather than publication time, so latency is bounded by your polling frequency regardless of architecture. I considered a shim service to translate Miniflux’s webhook payload for ntfy, but that’s another container to babysit for a job that didn’t need one.
Miniflux also has a native per-feed ntfy integration, which I initially dismissed: it pushes every new entry, with no freshness window, meaning a delayed feed fetch would dump an hour of backlog onto my phone at 0500. Ultimately I went with it anyway, paired with aggressive polling on the news feeds. The freshness filter turned out to be solving a problem I didn’t have: with a fast enough fetch cycle, entry arrival time converges on publication time, and the five-minute window enforces itself. Sometimes the simplest tool wins once you fix the constraint upstream.
And yes, an AI agent could watch feeds and “intelligently” summarise what deserves attention. When a workflow is this deterministic, cron is cheaper, faster, and fails in ways you can read in a log file. Constraint-driven design, same as it ever was.