A sidekick for miniflux, an open-source RSS reader, which promotes social RSS.
feederss reads a miniflux database directly and renders a small static site summarizing who subscribes to what, which categories they've configured, which articles they've starred, and then syncs that site to S3-compatible object storage.
It runs as a sidecar container next to miniflux, refreshing on an interval.
miniflux's postgres ──► feederss ──► object storage ──► your static site
(every hour)
buildqueries the miniflux database and renderspublic/index.html,public/about.htmlandpublic/data.jsonfrom the templates infeederss/templates/.publishdoes the same, then syncs everything underpublic/to the bucket — uploading only files whose MD5 differs from the object's ETag, and deleting objects that no longer exist locally.looprepeats step 2 everyREFRESH_INTERVAL_SECONDS. This is what the container runs.
Everything is environment variables, read from the real environment or from a
.env file in the working directory. Start from .env.example
| variable | what it is |
|---|---|
DB_URL |
Postgres connection string for the miniflux database |
APP_URL |
URL of your miniflux instance, linked from the site header |
Not needed for build.
| variable | default | what it is |
|---|---|---|
S3_BUCKET |
— | bucket to publish into |
S3_ACCESS_KEY_ID |
— | access key |
S3_SECRET_ACCESS_KEY |
— | secret key |
S3_ENDPOINT_URL |
(AWS S3) | set for any S3-compatible provider — DigitalOcean Spaces, Cloudflare R2, MinIO, … |
S3_REGION |
us-east-1 |
region, where the provider uses one |
| variable | default | what it is |
|---|---|---|
CHAT_URL |
(none) | chat link in the site header; omit it and the link isn't rendered. this could be a signal group, discord channel, etc. |
REFRESH_INTERVAL_SECONDS |
3600 |
how long loop sleeps between runs |
PUBLIC_DIR |
./public |
where the site is rendered |
S3_PREFIX |
(none) | publish under a key prefix instead of the bucket root |
S3_ACL |
(none) | canned ACL for uploaded objects. Left unset, no ACL is sent, which is what modern S3 buckets require (ACLs are disabled by default there, and public access comes from a bucket policy). Set public-read for providers that still expect per-object ACLs, like DigitalOcean Spaces |
S3_DELETE_ORPHANS |
true |
delete bucket objects the generator no longer produces |
S3_HTML_CACHE_CONTROL |
public, max-age=300 |
Cache-Control for .html/.json |
S3_ASSET_CACHE_CONTROL |
public, max-age=3600 |
Cache-Control for css/js/images |
HEARTBEAT_FILE |
/tmp/feederss-heartbeat |
touched after each successful run, read by the healthcheck |
NUM_USER_STARRED_ENTRIES |
10 |
starred entries shown per user |
NUM_RECENT_STARRED_ENTRIES |
20 |
entries in the "recently starred" list |
NUM_RECENTLY_ADDED_FEEDS |
20 |
feeds in the "recently added" list |
make install # pip install -r requirements.txt
cp .env.example .env # then fill it in
make build # render the site into public/
make start # serve public/ at http://localhost:3030
make watch # rebuild whenever feederss/ changes (needs entr)
make publish # render AND sync to object storagemake build needs to reach the miniflux database. Miniflux's Postgres usually
isn't published outside its Docker network, so from a workstation that means
tunnelling to it first:
ssh -L 5432:localhost:5432 your-miniflux-host
# then in .env:
# DB_URL=postgres://<user>:<password>@localhost:5432/<db>?sslmode=disablemake docker-build # build the image
make docker-run # run it against your .envThe image runs python -m feederss loop as a non-root user and ships a
HEALTHCHECK that fails once the last successful refresh is older than two
intervals — so a wedged refresh surfaces as an unhealthy container rather than
a site that quietly stops updating.
A cron daemon would have been the other option; the sleep loop is deliberate. cron in a container doesn't inherit the container's environment (you'd have to export it into a crontab by hand), it logs somewhere other than stdout, and it gives docker nothing to healthcheck against.
Each subcommand is also runnable directly:
docker run --rm --env-file .env registry.gitlab.com/abelsonlive/feederss \
python -m feederss publishAdd it to miniflux's own compose project, so it can reach miniflux's Postgres over the compose network. It publishes outward only — no ports, no reverse proxy, nothing to route:
services:
miniflux:
image: miniflux/miniflux:latest
# ...
miniflux-postgres:
image: postgres:16-alpine
# ...
feederss:
image: registry.gitlab.com/abelsonlive/feederss:latest
restart: unless-stopped
environment:
DB_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@miniflux-postgres/${POSTGRES_DB}?sslmode=disable
APP_URL: https://miniflux.example.com
REFRESH_INTERVAL_SECONDS: "3600"
S3_BUCKET: your-bucket
S3_ENDPOINT_URL: https://nyc3.digitaloceanspaces.com
S3_REGION: nyc3
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
depends_on:
miniflux-postgres:
condition: service_healthyIf you want to add a link to feederss in the header, you can add this javascript snippet in Miniflux's settings:
// ==UserScript==
// @name Miniflux: feederss link
// @match https://rss.abelson.live/* // INSERT THE DOMAIN FOR MINIFLUX HERE
// @grant none
// @run-at document-end
// ==/UserScript==
(() => {
const HREF = 'https://feederss.abelson.live'; // INSERT THE DOMAIN FOR FEEDERSS HERE
const LABEL = '😋😋😋';
const menu = document.querySelector('#header-menu');
if (!menu) return;
const a = document.createElement('a');
a.href = HREF;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = LABEL;
const li = document.createElement('li');
li.append(a);
menu.append(li);
})();Providers differ in how they serve a static site:
- Most need an explicit website configuration (an index document) before
/returnsindex.htmlinstead of a listing or a 403. On S3 that'sPutBucketWebsite; the same call works on S3-compatible providers. - Website endpoints usually route by Host header, which means the bucket
has to be named after the domain (
feederss.example.com, notfeederss). - Custom-domain HTTPS generally needs the provider's CDN or another proxy in front, because the bucket endpoint only presents the provider's own wildcard certificate.
The site links its own pages as /, so it needs a host that serves an index
document there.
AWS satisfies all three at once, which is what this is deployed on: enable
static website hosting on the bucket, make it public with a bucket policy
(not per-object ACLs, which modern buckets reject), then put CloudFront in
front with the website endpoint — <bucket>.s3-website.<region>.amazonaws.com
— as a custom origin over plain HTTP, plus an ACM cert for your domain. The
website endpoint is what supplies the index document; CloudFront supplies the
certificate.
One more thing worth setting while you're there: data.json is a public
read-only dump of everything the site renders, and it's genuinely useful to
read from elsewhere. Doing that from a browser needs an
Access-Control-Allow-Origin header, which object stores don't send by
default. On S3 that's a bucket CORS rule (GET/HEAD) — and if you're behind
a CDN, the CDN has to both send the header and forward Origin plus
Access-Control-Request-* upstream, or preflight requests fail even though
plain GET looks fine.
Not every provider lets you combine them. DigitalOcean Spaces, for instance,
pulls in opposite directions: the <bucket>.<region>-static.digitaloceanspaces.com
endpoint honors the index document but only presents a
*.<region>-static.digitaloceanspaces.com cert, while the CDN endpoint
terminates TLS for your own domain but fronts the origin endpoint, so /
403s. Its CDN refuses to take the -static host as an origin, so you can't
have both without putting something like Cloudflare in front.
CI builds the image on every push and, on the default branch, pushes it to
this project's container registry as both
registry.gitlab.com/abelsonlive/feederss:<short-sha> and :latest. It
authenticates with GitLab's built-in job token, so there is nothing to
configure. The project is public, so the images pull anonymously.
