FeedEcho
Your feeds, echoed everywhere.
Self-hosted RSS feed cross-poster. Route items from RSS, Atom, and JSON feeds to Mastodon, Bluesky, micro.blog, Matrix, Discord, Telegram, generic webhooks, GitHub repositories, and email using configurable templates.
Inspired by, and built as a replacement for [Echofeed](https://rknight.me/blog/shutting-down-echofeed/), which began shutting down in August 2026.
A hosted version with accounts, plans, and a 14-day free trial is live at [feedecho.net](https://feedecho.net). The self-hosted, single-tenant mode documented here is the complete cross-posting product.
## Features
- **RSS/Atom/JSON feed support** via feedparser
- **Mastodon OAuth** — connect accounts with one click, no manual token creation
- **Bluesky support** — connect accounts with an App Password; posts get auto-detected link and hashtag facets (links clickable, hashtags as real, searchable tags), 300-grapheme truncation, link cards with the linked page's Open Graph title and image, and image embeds with alt text
- **micro.blog support** — connect with a Micropub app token; FeedEcho discovers every blog the token can post to and posts with the item's image attached
- **Matrix support** — connect a room with an access token; posts go in as `m.room.message` events with clickable links, uploaded images, and homeserver-side de-duplication on retries (optionally with full-HTML formatted bodies via the render_html echo flag)
- **Discord support** — connect a channel with a webhook URL; posts land in the channel with an embed carrying the title, link, and image
- **Telegram support** — connect a bot token + chat; posts land in the chat (public channels get a t.me link in post history)
- **Generic webhooks** — POST items as JSON to any HTTP endpoint: Slack and Mattermost incoming webhooks, ntfy, Gotify, Zapier, n8n, push services like brrr, or anything you run yourself (optional custom JSON body template per endpoint)
- **GitHub** — write each item as a markdown file into a repository through the contents API: a static-site content folder, a link log, or a feed backup. Each echo carries a file-path template (with the `| slug` filter for filename-safe titles) and an optional commit-message template; your echo template becomes the file body, so front matter is just template text
- **Template engine** — sandboxed Jinja2 templates with conditionals, filters, and a live Preview button: `{{ title }}`, `{{ link }}`, `{{ content_link }}`, `{{ summary }}`, `{{ content }}`, `{{ content_html }}` (sanitized HTML passthrough for markup-aware destinations), `{{ author }}`, `{{ date }}`, `{{ date_iso }}`, `{{ date_short }}`, `{{ tags }}`, `{{ hashtags }}`, `{{ categories }}` (RSS/Atom category domains), `{{ image_url }}`, `{{ feed_name }}`, and the full `{{ item }}` dict
- **Multiple accounts** — post to multiple Mastodon instances, Bluesky accounts, micro.blog blogs, Matrix rooms, Discord channels, Telegram chats, webhook endpoints, and GitHub repositories
- **Per-feed poll intervals** — each feed checked on its own schedule
- **Built-in feed reader** — read items in place instead of a third-party app: folders with OPML import/export, unread and starred tracking with CSV/JSON export of starred items, saved searches, a full-text view, and a compose desk that turns any item into a post without leaving FeedEcho
- **Post history** with success/failure tracking, error messages, and per-feed / per-destination filtering
- **Visibility settings** — public, unlisted, private, direct (Mastodon)
- **Drip mode** — cap an echo at N posts per hour; bursts queue up and release as the sliding window allows instead of flooding your timeline
- **Auto-queue mode** — send new items into the curation queue on a schedule instead of posting immediately
- **Review-first mode** — stage new items in the curation queue as drafts and approve them before anything goes out
- **Filter field prefixes** — echo filter keywords accept field scoping: plain text matches title and summary, while `tag:`, `author:`, `url:` (or `link:`), and `content:` match that field
- **Content warnings** — per-echo CW text applied as Mastodon spoiler text
- **Markdown posting** — per-echo flag on Mastodon destinations that sends `content_type=text/markdown`, so GoToSocial and other Markdown-capable servers render `**bold**` and `[links](url)`; stock Mastodon ignores the field and treats the body as plain text
- **Image attachments** — automatically attach the feed item's images: up to 4 per post on Mastodon and Bluesky, up to 4 inline on email, and the first image on Matrix, micro.blog, Discord, and Telegram
- **AI alt text** — optionally generate image descriptions via an OpenAI-compatible vision API. Feed-provided descriptions win by default; a per-feed override prefers the generated description instead, with the feed's version retained if the API is disabled, returns nothing, or fails
- **Digest mode** — batch email deliveries into hourly digests instead of one email per item
- **Mobile-responsive** — tables convert to cards, forms stack, 44px touch targets
- **Idempotent posting** — failed posts are retried, duplicates are prevented
- **Auto-initialization** — feeds set their baseline on first fetch, no manual init needed
- **Import/export** — back up or move your whole setup (feeds, echoes, and per-destination accounts) as one JSON file; import is idempotent and re-links echoes to the recreated rows
- **Email destination** — echo to email via SMTP in addition to Mastodon, Bluesky, micro.blog, Matrix, Discord, and webhooks, with optional full-HTML rendering (instant mode) and inline image embedding
## Tech Stack
- **Backend**: Python + FastAPI
- **Database**: SQLite (WAL mode) or Postgres
- **Frontend**: Jinja2 server-rendered templates + vanilla JS
- **Feed parsing**: feedparser (RSS/Atom) + native JSON Feed parser
- **Scheduler**: APScheduler (background feed checker)
- **HTTP client**: httpx
## Quick Start
### Docker (recommended)
Pre-built multi-arch images (amd64 + arm64) are published to GHCR on every release — no local build needed:
```bash
mkdir feedecho && cd feedecho
cat > .env <<'EOF'
FEEDECHO_AUTH_TOKEN=change-me-to-a-long-random-string
FEEDECHO_CALLBACK_URL=http://localhost:8453/oauth/callback
EOF
curl -O https://raw.githubusercontent.com/jcrabapple/feedecho/master/docker-compose.yml
# Then edit docker-compose.yml: comment out `build: .` and uncomment the `image:` line
docker compose up -d
```
Or clone the repo and build locally:
```bash
git clone https://github.com/jcrabapple/feedecho.git
cd feedecho
# Set your access token (required) and public URL (for Mastodon OAuth)
cat > .env <<'EOF'
FEEDECHO_AUTH_TOKEN=change-me-to-a-long-random-string
FEEDECHO_CALLBACK_URL=http://localhost:8453/oauth/callback
EOF
docker compose up -d
```
Open `http://localhost:8453` and log in with your `FEEDECHO_AUTH_TOKEN`.
Data lives in the `feedecho-data` volume (`/app/data` in the container) — your feeds, accounts, and history survive `docker compose up -d --build` rebuilds.
Plain Docker without compose:
```bash
docker build -t feedecho .
docker run -d --name feedecho \
-p 8453:8453 \
-v feedecho-data:/app/data \
-e FEEDECHO_AUTH_TOKEN=change-me-to-a-long-random-string \
-e FEEDECHO_CALLBACK_URL=http://localhost:8453/oauth/callback \
feedecho
```
Updating later? A running install does not pick up new releases by itself. See [Updating](#updating).
### Environment variables
| Variable | Required | Purpose |
|----------|----------|---------|
| `FEEDECHO_AUTH_TOKEN` | yes | Shared-secret login for the web UI. **The app refuses to start without it** unless you set `FEEDECHO_ALLOW_INSECURE=1` (no auth at all — only sane behind your own authenticated reverse proxy). |
| `FEEDECHO_ALLOW_INSECURE` | no | Set to `1` to boot single-user mode with no `FEEDECHO_AUTH_TOKEN` and no authentication. Logs a loud warning at startup. |
| `FEEDECHO_CALLBACK_URL` | for Mastodon OAuth | Public callback URL, e.g. `https://feedecho.example.com/oauth/callback`. Must match the URL reachable by your browser. Derived from `FEEDECHO_BASE_URL` when unset. |
| `FEEDECHO_BASE_URL` | no | Public base URL of your install. Used to derive the OAuth callback and the app website shown on posts. |
| `FEEDECHO_APP_WEBSITE` | no | Link behind the "FeedEcho" application name on Mastodon posts. Defaults to `FEEDECHO_BASE_URL`, then to the project repo. |
| `FEEDECHO_DB_PATH` | no | SQLite path (default `/app/data/feedecho.db` in Docker, `./feedecho.db` otherwise) |
| `FEEDECHO_STATE_SECRET` | required in multi mode (32+ chars) | OAuth state signing secret. In multi mode the app refuses to start without it. Single mode falls back to `FEEDECHO_AUTH_TOKEN`, then a random per-process value. |
| `FEEDECHO_ALLOW_BACKDATED_ENTRIES` | no | Set to `1` to deliver feed items that appear positionally older than the cursor but whose publish date is within `FEEDECHO_MAX_BACKDATED_ENTRY_DAYS` of now. Off by default. |
| `FEEDECHO_MAX_BACKDATED_ENTRY_DAYS` | no | How many days back to accept backdated entries (default `3`). Only consulted when `FEEDECHO_ALLOW_BACKDATED_ENTRIES=1`. |
| `FEEDECHO_BOOSTER_URL` / `FEEDECHO_BOOSTER_TOKEN` | no | FeedBooster service URL and shared token. When both are set, Mastodon destinations get a Boost toggle — per account on /accounts and per echo on /echoes (echoed posts are announced by the booster's fediverse account). Without them, the feature is hidden and inert. |
Behind a reverse proxy (nginx, Caddy, Traefik), point the proxy at port `8453` and set `FEEDECHO_CALLBACK_URL` to the public HTTPS URL.
### From source
```bash
git clone https://github.com/jcrabapple/feedecho.git
cd feedecho
python -m venv .venv
source .venv/bin/activate
pip install fastapi "uvicorn[standard]" jinja2 python-multipart feedparser httpx apscheduler cryptography pillow nh3
FEEDECHO_AUTH_TOKEN=your-token python -m uvicorn app:app --host 0.0.0.0 --port 8453
```
### NixOS
FeedEcho ships a Nix flake and a NixOS module. See [`nix/README.md`](nix/README.md) for full instructions.
```nix
{
inputs.feedecho.url = "github:jcrabapple/feedecho";
outputs = { self, nixpkgs, feedecho, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
feedecho.nixosModules.default
{
services.feedecho = {
enable = true;
authTokenFile = "/run/secrets/feedecho-token";
callbackUrl = "https://feedecho.example.com/oauth/callback";
};
}
];
};
};
}
```
### Updating
FeedEcho does not update itself. A container keeps running the image it was created from, and `docker compose up -d` does not check the registry for a newer `latest`, so an install stays on the version it started with until you update it. Your version is in the footer of every page once you are logged in, and it links to the [release notes](https://github.com/jcrabapple/feedecho/releases). Check it after an update to confirm the new build is the one running.
Your data lives in the `feedecho-data` volume (or wherever `FEEDECHO_DB_PATH` points) and survives every update below. You do not need to re-add feeds, accounts, or echoes.
If you use the pre-built image (the `image:` line is active in your compose file), pull the new image and recreate the container:
```bash
docker compose pull
docker compose up -d
```
To have every `up` check the registry for you, uncomment `pull_policy: always` in `docker-compose.yml`, or run `docker compose up -d --pull always`.
If you build from a clone (`build: .` is active), there is no image to pull. Update the code and rebuild:
```bash
git pull
docker compose up -d --build
```
With plain Docker, update the code, rebuild the image, and recreate the container with the same `docker run` options you used the first time. The named volume keeps your data:
```bash
git pull
docker build -t feedecho .
docker rm -f feedecho
# then run your original `docker run ...` command again
```
If you manage the container through a NAS or container-manager UI, the mechanics are the same: pull the new image, then recreate the container.
From source, run `git pull`, re-run the `pip install` line under [From source](#from-source) (releases occasionally add a dependency), and restart the process. If you run it as the systemd user service under [Deployment](#deployment), that is `systemctl --user restart