Contents

FreshRSS and the Art of a Quiet Feed

The extensible reader, WebSub push, and building a river that stays calm

Contents

Last week I wrote about Miniflux and its refusal to grow features, and the response from several people amounted to the same sentence: fine, but I want the features.

That is a completely legitimate position and FreshRSS is the answer to it. Where Miniflux closes issues, FreshRSS ships an extension API and lets the community close them for itself. Where Miniflux has one level of categories on principle, FreshRSS has labels, saved queries, and a dozen ways to slice the same articles. They occupy the same category and share almost no design values, which makes the comparison genuinely useful.

I have been running FreshRSS in parallel for a couple of months against the same 180-odd feeds. Here is what the extra surface buys you and what it costs.

The Case for a PHP App in 2024

Advertisement

Let us get the sneering out of the way. FreshRSS is PHP, it wants a web server, and it stores data in MySQL, Postgres or SQLite depending on your mood. Against a landscape of single static binaries it reads as dated.

It is also why the thing is easy to live with. The database options mean you can start with SQLite and a single container, and the “single container” claim is real — the image bundles the web server and the cron daemon that refreshes your feeds. There is no compose file with four services and a healthcheck dance.

More importantly, PHP is why the extension system exists and works. An extension is a directory you drop in a folder. No build step, no compilation, no recompiling the reader to add a feature. That model has been unfashionable for fifteen years and it is the reason FreshRSS has a community catalogue of extensions written by people who are not developers by trade.

The project has been going since 2012, has had a stable release cadence throughout, and its 1.24 line is the current one. That is a longer track record than most of what is in my rack.

Getting It Running

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
services:
  freshrss:
    image: freshrss/freshrss:latest
    restart: unless-stopped
    ports:
      - "8080:80"
    environment:
      TZ: Europe/Copenhagen
      CRON_MIN: '3,33'
      FRESHRSS_ENV: production
    volumes:
      - freshrss_data:/var/www/FreshRSS/data
      - freshrss_ext:/var/www/FreshRSS/extensions

volumes:
  freshrss_data:
  freshrss_ext:

That is the whole thing. CRON_MIN is the schedule for the internal refresh job — those are minutes past the hour, and picking odd numbers rather than 0,30 is a small courtesy to the servers you poll, which get hammered on the hour by everyone who left the default in place.

Two volumes matter. The data one holds your database, your configuration and your users. The extensions one holds anything you install later; skip it and every extension vanishes on the next image pull, which is a lesson people tend to learn exactly once.

Setup happens in the browser. It asks which database you want, and unless you have thousands of feeds or several users, choose SQLite and stop thinking about it. My install with 180 feeds and 60 days of retention sits at 340 MB on disk and responds instantly.

Put it behind your usual front door. If you are running the Traefik or NPM setup from the reverse proxy comparison, FreshRSS needs nothing exotic — plain HTTP proxying and correct forwarded headers. It also speaks the auth-proxy protocol properly, so if you already terminate identity at Authelia or Authentik, FreshRSS can trust a header and skip its own login entirely. That integration is a genuine strength and it is buried in an authentication dropdown.

The Features You Came For

Advertisement

WebSub. This is the one that produces an actual visceral difference. Feeds that support WebSub — the protocol formerly called PubSubHubbub — push to your reader the moment they publish. No polling interval, no waiting. FreshRSS registers as a subscriber and the article lands in seconds.

The catch is that your instance must be reachable from the internet for the hub to call it, which is a meaningful decision to make about a personal reader. If your FreshRSS lives on a VPN-only address, WebSub does nothing and you are back to polling. Weigh that honestly before you punch a hole in anything.

XPath scraping. FreshRSS can create a feed from a page that has no feed. You give it a URL, tell it that the feed type is “HTML + XPath”, and hand it selectors for the item, the title, the link and the body:

1
2
3
4
5
Feed type:      HTML + XPath
XPath (items):  //article[contains(@class,"post")]
XPath (title):  .//h2/a
XPath (link):   .//h2/a/@href
XPath (content):.//div[@class="entry"]

This is how you follow a site that removed its feed in 2019 because analytics told them nobody used it. It is fiddly, it breaks when the site redesigns, and it is the single most satisfying feature in any reader I have used. Miniflux’s scraper rules improve an existing feed; this invents one from nothing.

User queries and labels. A saved search becomes a view in the sidebar. Labels cross-cut categories. If you want “everything from any feed mentioning ZFS, regardless of category, unread only”, that is a query you save once and click forever.

Extensions. The catalogue includes reading time estimates, YouTube feeds that embed the player, an auto-refresh, a keyboard shortcut extension, and a “sticky feeds” one. Install by dropping a folder in the extensions volume and enabling it in the UI. They are of variable quality and a couple have broken across upgrades in my experience, which is the exact trade you are making.

API compatibility. Google Reader and Fever APIs both, so the third-party mobile clients work — the same story as Miniflux, with the same conclusion.

Troubleshooting

Feeds never refresh. This is the number one FreshRSS problem and it is almost always the cron job. The container runs its own scheduler, and if the container’s timezone is wrong or CRON_MIN is malformed, it silently does nothing. Check that the job is alive and look at the actual update log:

1
2
docker compose exec freshrss ps aux | grep -i cron
docker compose exec freshrss cat /var/www/FreshRSS/data/users/_/log.txt | tail -30

You can force a refresh from the command line, which is also the correct way to run it from an external scheduler if you would rather:

1
docker compose exec freshrss ./cli/actualize-user.php --user smarc

PHP memory exhausted during a big import. The default memory_limit will fail on an OPML file with several hundred feeds, and the error surfaces as a blank page rather than a message, which is a very PHP way to fail. Import in chunks, or raise the limit via the image’s PHP configuration.

The database is locked (SQLite). Two processes writing at once — usually a manual actualize run colliding with the internal cron. Pick one scheduler. If you genuinely need concurrent writers and multiple users, that is your signal to move to Postgres, and FreshRSS has a documented conversion path.

Extensions disappear after docker compose pull. You skipped the extensions volume. See above.

WebSub subscriptions expire and stop delivering. Hubs require periodic renewal and FreshRSS handles it, provided the callback URL is still correct. If you changed your external hostname, every existing subscription is now pointing at nowhere and will fail quietly. Re-subscribe the affected feeds.

The Honest Comparison

Both of these are good. Choosing between them is choosing a philosophy, so here it is without hedging.

FreshRSS wins on extensibility, XPath feed creation, WebSub push, multi-user with real permissions, auth-proxy integration, saved queries, and being one container with SQLite. It is the better choice if your household has three readers, if you want to follow feedless sites, or if you enjoy tinkering with the tool as much as reading with it.

Miniflux wins on resource footprint, a hard 40 MB against FreshRSS’s 150–200 MB with PHP-FPM warm, the absence of a plugin surface to break on upgrade, and a codebase small enough to audit. It is the better choice if the reader should be furniture.

The resource gap is real and it is also 150 MB, which is a rounding error on anything with 8 GB. Do not let anyone tell you this decides it.

The upgrade risk is the more honest differentiator. In two months I have had one extension break on a point release. That is the tax on the extension system and it is entirely fair — the extension was written by a volunteer against an internal API — and it is a category of failure that cannot happen to Miniflux, because there is nothing to break.

The Quiet Feed

The title of this post is doing some work, so let me cash it.

Both readers will happily give you 1,400 unread items. FreshRSS gives you more tools to shape the flood and every one of those tools is a way to spend an evening configuring instead of reading. I built an elaborate label taxonomy in my first fortnight and have used exactly two of the labels since.

The settings that actually made the feed quiet were unglamorous. Retention down to 45 days, so the archive stops being a guilt pile. Unread counts hidden for the news categories, because a number in a badge is a slot machine. Feeds sorted by how often they publish rather than by subject. And the sharpest one: a monthly cull of anything I have not finished an article from.

The extension I would actually recommend is the reading-time one, because it turned “1,400 unread” into “nineteen hours of reading”, and nineteen hours is a number you can argue with.

Backups, and the Thing People Forget

A feed reader looks disposable until you have curated it for four years, at which point the OPML file is a genuinely irreplaceable artefact — a record of who you decided was worth listening to, assembled slowly, impossible to rebuild from memory.

Export it on a schedule. FreshRSS has a CLI for this and it belongs in cron on the host:

1
docker compose exec -T freshrss ./cli/export-opml-for-user.php --user smarc   > "/backup/freshrss-$(date +%F).opml"

That file is a few kilobytes and it is ninety per cent of the value. The rest — read state, labels, saved queries, extension settings — lives in the data volume, so back that up too if you care about the state, though be honest with yourself about whether you would ever restore “which articles I had read in 2023”.

The SQLite database is a single file and copying it while the cron job is writing gives you a corrupt copy roughly one time in fifty, which is exactly the ratio that lets you discover the problem on the worst possible day. Use the online backup API rather than cp:

1
docker compose exec -T freshrss sqlite3 /var/www/FreshRSS/data/users/smarc/db.sqlite   ".backup '/var/www/FreshRSS/data/users/smarc/backup.sqlite'"

Then copy the backup file, which is quiescent by definition. This is the same discipline that applies to every SQLite-backed service in the rack, and it is worth building the habit here where the stakes are low.

One more: keep the OPML somewhere that survives the reader. Mine goes into the same offsite set as everything else, because the failure mode I am insuring against is losing the machine, and a backup that lives on the machine is a copy rather than a backup.

Verdict

If you want a feed reader that adapts to you, FreshRSS is the best self-hosted option available and it has a twelve-year track record to back that up. The XPath scraping alone justifies the install if you follow even one site that killed its feed. Run it with SQLite, put the extensions in a volume, pick odd cron minutes, and it will ask nothing of you.

If you want a feed reader that has no opinions and no moving parts, Miniflux remains the better tool and I still run it as my daily driver — through pure inertia, which is exactly the compliment that kind of software is fishing for.

Anyone reading this to decide which to install first: install FreshRSS. It is one container, it takes ten minutes, and if the extension surface turns out to be a distraction you have lost an evening and learned something about your own habits. That is a cheap experiment and the alternative — arguing about readers on the internet — costs more.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.