Everything you want to read, in one self-hosted app: the feeds you follow, and the links, notes, screenshots and emails you save from anywhere. It runs as one Docker container and keeps everything in SQLite. Sign in with a password, Google, or any OpenID Connect provider. The browser extension and email worker live in clients/, and there's a free iPhone and iPad app that keeps read state in step with the web app.
liftbridgelabs.app/products/feedstash · questions and bug reports: GitHub issues or support@liftbridgelabs.app · privacy
| The stash as a grid | A saved copy, in Sepia | Tiles, in Nord dark |
|---|---|---|
![]() |
![]() |
![]() |
Feeds
- Folders and feeds. Follow a site or feed URL (the feed is found automatically), or a subreddit by typing
r/and its name, from the + beside Feeds, which also makes folders. + Add → Discover feeds has starter packs to search by name or topic. Rename, move, reorder by drag and drop, or unfollow — in the sidebar, or all at once under Settings → Feeds → Organize feeds. - Mark as read. Everything, or only articles older than 12 hours, 1 day or 1 week, with Undo. Articles can also be marked read as they scroll off the top of the list (off by default).
- Auto-refresh. Feeds are fetched in the background; new articles appear on their own, or behind a "↑ N new articles" button while you're reading.
- Search. The search box in the toolbar (or press
/) searches the full text of the articles in the feed or folder you're viewing, read ones included. In the stash, the same box searches what you've saved. - Full articles. For feeds that only send a summary, Full article in the reader fetches the article's own page and shows its readable part (Feed version switches back). Choose Open articles as… → The full article in a feed's ⋯ menu and every new article opens that way, fetched in the background and kept for offline reading in the phone app. Needs
PAGE_CAPTUREon (the default). - Open on the website. For sites whose feed is only a teaser, Open articles as… → The website opens that feed's articles on the site itself, in a new tab (in the phone app, its browser), and marks them read.
j/kthen only move between them. The choice is kept per feed on the server, so every device agrees. - Muted words. Under Settings → Feeds → Muted words, list words or phrases you don't want to read about, everywhere or only in one folder or feed. New articles holding one (in the title, or in the text too if you choose) arrive already read, so every device and app agrees without knowing about muting. Muting a word can also mark the unread articles it matches as read, with Undo.
- Auto-save to the stash. Tick Auto-save for a feed (in Organize feeds, or its ⋯ menu) and its new articles go straight to your stash instead of waiting to be read.
- OPML import and export, e.g. from Feedly.
- Move to another FeedStash: download everything in your account as one file, and bring it in on any server.
Stash
- Capture from anywhere. The browser extension, forwarding an email, the share sheet on iPhone and iPad, or + Add → Save something in the web app: one form for a title, notes, links and pictures (paste them straight in), and FeedStash works out whether it's a link, a note or a screenshot. An item can hold several labeled links and up to 20 pictures.
- Email to your stash. Connect a mailbox under Settings → Stash → Email to your stash (Gmail, iCloud, Fastmail, Yahoo, Zoho or any IMAP server, with an app password) and anything you forward there is saved: subject as the title,
#hashtagsas tags, links saved with previews, and the first image attached. Write$Folderin the subject to file it (a folder that doesn't exist yet is created), or$"Two words"for a name with spaces — markers also work on a body line that holds nothing but markers, so$5 offin a newsletter stays text. FeedStash checks the mailbox itself, so this needs no open ports and works behind a proxy that asks for its own sign-in. For instant delivery on your own domain, the Cloudflare email worker is the alternative: Settings → Apps → Email with Cloudflare has the script to download and the steps. - Come back to it. Everything you save is in Everything saved. Show any view as a list, a Pinterest-style grid or even tiles; each folder, smart list and tag remembers its own. Edit, tag, search, filter by kind and tag, and archive or delete what you're done with.
- Link previews and saved copies. When you save a link, FeedStash fetches the page in the background: the list shows its title, description and image, and opening the item shows a readable copy of the article that stays even if the site changes or goes away.
- Full-text search. Stash search covers titles, notes, links, tags and the text of saved pages, and matches other forms of a word ("reefs" finds "reef").
- Folders. Keep saved items together by project or topic: make a folder in the sidebar, then drag items onto it, pick it as you save, or use the folder picker on an open item. An item is in one folder at a time; tags still work across everything.
- Smart lists. Any search or filter can be saved to the sidebar with Save as smart list. It always shows what matches now.
- Rules. Under Settings → Stash → Stash rules, say what happens to something new: by website, address, title, feed or kind, it can be tagged, filed in a folder or archived. Rules run on everything that arrives (except bulk imports), and Run on everything saved applies them to what's already there.
- Save articles. "Save to stash" on any feed article (or press
b). - Import saved links from Feedly boards, browser bookmarks, Pocket or Raindrop, choosing for each file where its links go and how they're tagged.
Appearance. Light, dark or following your system, in nine full color schemes — FeedStash, Slate, Nord, Solarized, Gruvbox, Dracula, Rosé, Forest and Sepia — under Settings → You → Appearance. Each one repaints the whole app, not just the accent color. Article lists come as Magazine (a picture beside each title), Titles only or Cards (a big picture above each title, side by side on a wide screen), in compact, standard or comfortable spacing (the view options menu in the toolbar). All of it is kept per browser, so each device can look however you like.
Accounts. Several people can share one server; each has their own feeds and stash. Admins add and remove accounts in Settings → Admin → Accounts, or Invite someone: a one-use link (it lasts a day, a week or 30 days, and can name the email address the account gets) that lets whoever has it make their own ordinary account with their own password, so send it only to them. Pending invites are listed there and can be canceled. Each person also chooses how long their read articles are kept (Settings → You → Your account, 30 days by default; Read later is never deleted), and can hide the feeds or the stash if they only use one (Use).
Updates. After an update, admins see what changed the first time they open FeedStash, and the version in the sidebar says when a newer release is out (UPDATE_CHECK). Settings → Admin → About FeedStash has the version and links for feature requests, problems and support.
Keyboard: press ? in the web app for the list (it's also under Settings → You → Reading). j/k open the next/previous article, n/p select without opening, o open, v open original, m toggle read, s read later, b save to stash, c save something, / search, r refresh, Shift+A mark all read, Esc close.
docker run -d --name feedstash --restart unless-stopped \
-p 8672:8672 \
-e BASE_URL=http://localhost:8672 \
-v feedstash-data:/data \
liftbridgelabs/feedstash:latestOpen http://localhost:8672 and create the first account; it becomes the admin.
With Docker Compose instead:
cp .env.example .env # set BASE_URL, and sign-in options if you want them
docker compose up -d --buildEverything FeedStash stores lives in /data: the database (feedstash.db), uploaded images (uploads/) and the generated session key (secret.key).
Turn on any combination. The sign-in page shows whatever is configured.
On a fresh install the first visit shows Create the first account. That account is an admin and can add more accounts, set their passwords, and make other admins under Settings → Admin → Accounts. Everyone can change their own password in Settings.
- Setup only works from your own network (your LAN, Docker, Tailscale), so nobody who finds a new server on the internet can claim it first. Setting up a server you can only reach over the internet, like one on a VPS? Set
SETUP_TOKENto something only you know and enter it on the setup page. - If
ALLOWED_EMAILSorALLOWED_DOMAINSis set, the first account must use one of those addresses. - After 10 wrong passwords for one account (or 30 from one address) within 15 minutes, sign-in is paused for that account or address.
- Set
PASSWORD_LOGIN=falseif you only want Google or OIDC.
With email set up (below), the sign-in page has Forgot password?: it emails a link that works once, for an hour, to choose a new password, and signs out everywhere else. It answers the same whether or not an address has an account, and sends at most three links an hour per address.
Locked out, or no email set up? Reset a password from the server:
docker exec -it feedstash python -m app.cli set-password --email you@example.comFeedStash sends email for Forgot password? and, if you like, to deliver invite links. Any email service that offers SMTP works (SMTP2GO, Postmark, Mailgun, Amazon SES, Gmail with an app password, or a mail server on your own network). Set it up under Settings → Admin → Sending email and press Send a test email to me; the password is stored encrypted with the server's SECRET_KEY. Or set SMTP_HOST, SMTP_PORT, SMTP_SECURITY, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM and SMTP_FROM_NAME (see Configuration); settings saved in the app take their place. Without email, nothing changes: there's no Forgot password? link, and invite links are copied by hand.
The easiest way is in the app: an admin opens Settings → Admin → Sign-in → Add sign-in, picks Google or OpenID Connect, and pastes in the client ID and secret. The form shows the redirect URI to give the provider, <BASE_URL>/auth/sso/callback (one for each address in BASE_URL), so you can create the client there first. FeedStash checks the issuer address when you save, and you can add more than one provider, turn one off, or remove it. Each button can have an icon (a built-in one, or an image link such as the logo your provider serves) and a color, with a preview as you choose; Google's button keeps Google's own look. Try it runs a real sign-in in a new tab and reports whether it worked, which address came back and whether that address may sign in, without signing you out or making an account; it works on a provider that's turned off, so you can check one before anyone sees its button. Sign-in buttons → All hidden turns every Google and single sign-on button off at once, handy while testing; passwords keep working. The client secret is stored encrypted with the server's SECRET_KEY.
Each OpenID Connect sign-in has Who can sign in with this: Everyone Pocket ID allows (named after the sign-in) leaves it to a provider you run yourself, where you decide who gets in (a user group on its FeedStash client in Pocket ID or Authentik, say), so there's no list to keep. Only people on FeedStash's allowed list checks the list below the providers: addresses, or whole domains for a company or school (* in the addresses lets in everyone). Google always uses the allowed list, since anyone can have a Google account.
- Google: in Google Cloud Console → APIs & Services → Credentials, set up the OAuth consent screen (External; add yourself as a test user; "Testing" is fine for personal use), then Create credentials → OAuth client ID → Web application with the redirect URI from Settings. Google only accepts
httpsaddresses (andlocalhost), so Google sign-in won't work on a plainhttp://192.168.x.xaddress. - OpenID Connect: create a confidential client using the authorization code flow, with the redirect URI from Settings and the scopes
openid email profile. The issuer address is, for example,https://id.example.comfor Pocket ID,https://auth.example.com/application/o/feedstash/for Authentik,https://auth.example.comfor Authelia, orhttps://sso.example.com/realms/homefor Keycloak.
Google and one OIDC provider can also be set with environment variables, which suits setups kept in a compose file. These show in Settings → Admin → Sign-in, where they can be turned off and on, but can only be changed in the server's settings. Their redirect URIs are <BASE_URL>/auth/google/callback and <BASE_URL>/auth/oidc/callback.
| Variable | Example |
|---|---|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET |
From Google Cloud Console |
OIDC_ISSUER |
https://id.example.com |
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET |
From the provider |
OIDC_NAME |
Button text: "Sign in with Pocket ID" |
ALLOWED_EMAILS / ALLOWED_DOMAINS |
Added to the list kept in Settings |
Google and OIDC sign-ins need an email on the allowed list. If someone already has a password account, signing in with Google or OIDC under the same (verified) email opens that same account. An admin can also give a Google or OIDC account a password in Settings.
All settings are environment variables (see .env.example).
| Variable | Default | Meaning |
|---|---|---|
BASE_URL |
http://localhost:8672 |
The address you open FeedStash at, or several separated by commas (e.g. https://feedstash.example.com,http://192.168.1.50:8672 for away and at home). Google/OIDC sign-in returns to whichever one you used; the first is the main address. Session cookies are Secure whenever you're on https. |
PASSWORD_LOGIN |
true |
Password accounts and first-run setup. |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
Google sign-in (or add it in Settings → Admin → Sign-in). | |
OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET |
OpenID Connect sign-in (or add it in Settings → Admin → Sign-in). | |
OIDC_NAME, OIDC_SCOPES |
single sign-on, openid email profile |
Button label and requested scopes. |
ALLOWED_EMAILS, ALLOWED_DOMAINS |
Comma-separated. Who may sign in with Google or OIDC, along with the list in Settings → Admin → Sign-in. | |
PUID, PGID |
1000, 1000 |
The user and group the app runs as. The container makes /data theirs on start. |
PORT |
8672 |
Port inside the container. |
FORWARDED_ALLOW_IPS |
private networks in the Docker image, 127.0.0.1 otherwise |
Addresses or ranges whose X-Forwarded-* headers are trusted. The default covers a reverse proxy on the same machine or network. Set it to your proxy's address if the proxy reaches FeedStash from a public one. Never *: anyone could then claim any address and get past the sign-in rate limit, and setup would need SETUP_TOKEN. |
SETUP_TOKEN |
Lets the first account be created from outside your own network, by entering this on the setup page. When set, it is asked for everywhere, your own network included. Setup closes once the first account exists, so you can remove it afterwards. | |
LOG_REQUESTS |
changes |
Which requests get a line in the log, until an admin changes it under Settings → Admin → Logs: changes (saves, edits and anything that failed), errors (only failures), all (every request, including health checks and page loads, thousands of lines a day) or none. |
UPDATE_CHECK |
true |
Once a day, while an admin has FeedStash open, ask GitHub for the latest release so the sidebar can say when there's an update. Nothing is sent but the request itself. false turns it off. |
SECRET_KEY |
generated | Signs session cookies. Generated once into /data/secret.key when empty. |
SESSION_DAYS |
30 |
How long you stay signed in. |
REFRESH_INTERVAL_MINUTES |
15 |
How often feeds are fetched (5 or more). |
RETENTION_DAYS |
90 |
Feed articles older than this are deleted, read or not; Read later and the newest 50 per feed are kept. Read articles can go sooner: each account sets its own limit in Settings (30 days after reading by default). Stash items are never deleted automatically. |
MAIL_POLL_MINUTES |
5 |
How often a mailbox connected under Settings → Stash → Email to your stash is checked. Nothing runs at all until someone connects one, and it stops again when the last one is disconnected. |
ACCOUNT_LIMIT |
0 |
How many accounts the server takes, counting invites not yet used; 0 for no limit. Add account, invites and first-time Google or single sign-on sign-ins stop at the limit. |
STORAGE_LIMIT_GB |
0 |
How much the database and uploaded pictures may take. At the limit, new stash saves, pictures and imports stop with a message (feeds keep updating, and their cleanup keeps them in bounds), and Settings shows how much is used. 0 for no limit. |
MANAGED |
false |
For a server you run for someone else: hides Sending email, Sign-in and Logs from its admins (and refuses changes to them) and turns off the update notice, since you set those in the server's settings and update it yourself. Accounts and invites stay with its admins. |
SMTP_HOST |
The SMTP server that sends email (Forgot password?, invites), like mail.smtp2go.com. Or set email up in Settings → Admin → Sending email, which takes its place. |
|
SMTP_PORT |
587 |
Its port: usually 587 with STARTTLS, 465 with SSL. |
SMTP_SECURITY |
starttls |
starttls, ssl, or none (only for a mail server on your own network). |
SMTP_USERNAME / SMTP_PASSWORD |
The SMTP login, if it needs one. | |
SMTP_FROM |
The address email comes from. Use one your email service lets you send from. | |
SMTP_FROM_NAME |
FeedStash |
The name email comes from. |
PAGE_CAPTURE |
true |
Fetch the web page behind each saved link for its preview, readable copy and search text. Turn it off if the server shouldn't reach out to the sites you save. |
DEV_LOGIN |
false |
Local testing only: skips sign-in entirely. |
KEEP_PER_FEED |
50 |
How many of each feed's newest articles RETENTION_DAYS always keeps. |
COOKIE_SECURE |
depends on BASE_URL |
Force the session cookie's Secure flag on (true) or off (false). Normally worked out from BASE_URL: on for https addresses, and per request when you list both. |
DATABASE_PATH |
/data/feedstash.db in the image |
Where the database lives; secret.key and uploads go next to it. |
HOST |
0.0.0.0 in the image, 127.0.0.1 otherwise |
The address the server listens on. |
SCHEDULER_ENABLED |
true |
Fetch feeds on a timer. Off is only for tests. |
FEEDSTASH_VERSION is set by the image to its release; it's what the sidebar and /api/health show.
The image runs on amd64 and arm64. It starts as root just long enough to give /data to PUID:PGID (only when the owner is wrong), then runs the app as that user.
Use the template in deploy/unraid/feedstash.xml: copy it to /boot/config/plugins/dockerMan/templates-user/my-feedstash.xml, then Docker → Add Container and pick feedstash. Google and single sign-on are set up after install, in Settings → Admin → Sign-in, so the template leaves out the server settings for them; add GOOGLE_*, OIDC_* or ALLOWED_* as variables if you'd rather set sign-in on the server. Or add the container by hand:
| Field | Value |
|---|---|
| Repository | liftbridgelabs/feedstash:latest |
| Port | container 8672 → any free host port |
| Path | container /data → /mnt/cache/appdata/feedstash |
| Variables | BASE_URL, PUID=99, PGID=100, plus any sign-in settings |
Point /data at a pool (/mnt/cache/...), not /mnt/user/...: SQLite doesn't do well on the /mnt/user FUSE layer, and make sure the mover doesn't move the appdata share. To update: Docker → feedstash → Force update.
In Container Manager → Project, create a project from this compose file, change the volume to a folder such as /volume1/docker/feedstash:/data, and set PUID/PGID to the owner of that folder (run id your-user over SSH; on Synology the group is usually 100).
cp .env.example .env # BASE_URL=https://feedstash.example.com
docker compose up -dPoint the proxy at port 8672. Caddy:
feedstash.example.com {
reverse_proxy localhost:8672
}
With Nginx Proxy Manager, add a proxy host for the container's IP and port 8672 and request a certificate; websockets aren't needed. The image trusts X-Forwarded-For only from private networks, which is where a proxy like this runs, so nothing needs setting. If your proxy reaches FeedStash from a public address instead (a proxy on a VPS in front of a home server), set FORWARDED_ALLOW_IPS to that address.
To use FeedStash both through the proxy and directly at home, list both addresses, the proxy's first: BASE_URL=https://feedstash.example.com,http://192.168.1.50:8672. You stay signed in on each, and the cookie is Secure on the https one. (Pointing the domain at the proxy from inside your network too, with local DNS, gives you one https address everywhere, which Google sign-in needs.)
Install Docker Desktop and use the compose file as above. Keep the named volume (feedstash-data) rather than a Windows folder for /data: SQLite is much happier on it.
Back up while it runs:
docker exec feedstash python -m app.cli backup /data/backups/feedstash-$(date +%F).dbThen copy /data/backups/ and /data/uploads/ somewhere safe. Or stop the container and copy the whole /data folder.
To update, pull the new image and recreate the container (docker compose pull && docker compose up -d, or Force update on unRAID). Database changes are applied automatically on start; take a backup first.
Logs. FeedStash keeps its own log files in /data/logs, rotated by size, and admins choose under Settings → Admin → Logs how detailed they are, which requests get a line, how big a file gets and how many old ones to keep; the latest lines can be read and the whole log downloaded there too. The container's own output (docker logs) only gets warnings, errors and start-up lines, so it stays small however Docker is set up.
Each release's notes are in CHANGELOG.md and on the Releases page (watch the repository for releases to hear about them). Check Before you update for anything to do first, like taking a backup. After an update, admins see what changed the first time they open FeedStash, and the version in the sidebar says when a newer release is out.
Other admin commands: create-account, set-password, list-accounts (docker exec feedstash python -m app.cli --help).
FeedStash fetches web addresses on your behalf: feeds you follow, and the pages behind links you save. It will fetch anything on your own network, because people follow feeds there. That means anyone with an account can make it fetch an address inside your network and read the reply (a saved link's copy shows what came back). For one person, or a household that trusts each other, that's fine. Don't hand out accounts to people you wouldn't let onto your network. The one thing it never fetches, for anyone, is a link-local address (169.254.x.x, fe80::), where cloud metadata services hand out credentials.
Settings → Feeds → Move to another FeedStash → Download everything gives you one .zip with everything in your account: feeds and folders, articles with what you've read and starred, your stash with its tags, links, pictures and saved pages, smart lists, rules, muted words and your reading settings. On the other server, Bring in from another FeedStash… adds it to your account there. It merges: a feed you already follow, a folder of the same name, an article or stash item that's already there is kept once, so bringing the same file in twice changes nothing. Your password, API tokens, connected mailbox and the server's own settings stay behind; set those up again on the new server.
For a whole server with every account, copy the database instead (python -m app.cli backup, see Deploying).
Download your data from Feedly, unzip it, then in FeedStash open Settings → Feeds → Import & export and choose that whole folder:
- Your feeds and folders come in from
subscriptions.opmlstraight away, and FeedStash starts fetching them. - Each board (
my boards/) shows up as a row: tick whether to import it, pick Everything saved or the Archive, and set a tag. Original save dates are kept, and links you already have are skipped. - The Unsaved board (links you removed from Feedly) starts unticked, and the
read/folder (every article you ever opened, titles only) is left out.
If you only have the OPML, Import OPML in the same place takes it (and Export OPML gives you yours back). The bookmark-file import also takes bookmarks exported from Chrome, Firefox, Edge or Safari, and Pocket or Raindrop exports.
Each client signs in with its own API token, so you can revoke one without affecting the others.
- In the web app, open Settings → Apps → Connected apps and click New token. Copy the token; it's shown only once (only a hash is stored).
- Configure the client with your
BASE_URLand that token:- Browser extension: download it from Settings → Apps → Browser extension, unzip it, and load the folder with Load unpacked at
chrome://extensions(Developer mode on); then set the server URL and token in its options. The source isclients/extension/. - Email worker: download the script from Settings → Apps → Email with Cloudflare and paste it into a new Worker in the Cloudflare dashboard, with
FEEDSTASH_API_BASE,FEEDSTASH_API_TOKENandALLOWED_SENDERSset. The source isclients/email-worker/. - iPhone and iPad app: in the web app, Settings → Apps → Connected apps → Connect a phone or iPad shows a code to scan; or enter the server URL and a token in the app's Settings tab.
- Browser extension: download it from Settings → Apps → Browser extension, unzip it, and load the folder with Load unpacked at
See the extension's and email worker's READMEs for install steps.
FeedStash speaks the Google Reader API, the protocol most reader apps use to sync with a server of your own, so apps like NetNewsWire, Reeder Classic, lire, Capy Reader and Read You can read your feeds from it. In the app, add a FreshRSS or Google Reader account and enter:
| Field | Value |
|---|---|
| Server | your FeedStash address, e.g. https://feedstash.example.com. If the app rejects it, try https://feedstash.example.com/api/greader.php |
| Username | your FeedStash email |
| Password | an API token from Settings → Apps → Connected apps — make one per app, so you can revoke it on its own |
These apps see your feeds, folders, read state and Read later; marking something read in one shows up everywhere, since the state lives on your server. They can also follow, rename, move and unfollow feeds. They don't see the stash — the protocol has no idea it exists.
If a reverse proxy in front of FeedStash asks for its own sign-in, let /reader/*, /accounts/ClientLogin and /api/greader.php/* through, as you would for /api/*. None of the clients has been built and tried against FeedStash yet.
The clients use these endpoints (camelCase fields, {"error": "..."} on failures):
POST /api/items(JSON or multipart;type(link,snippetfor a note,screenshot,email),title,content,url,links,tags,image(repeat it for several pictures),source,folderId,pageHtml)- Pictures: an item's
imageslists them all andimagePathis the first.POST /api/items/{id}/images(multipartimageparts) adds more;PATCH /api/items/{id}withimages(a list of its own paths) removes or reorders them. Up to 20 per item, 20 MB each; a request is capped at 32 MB, so send large ones one per request. GET /api/items?type=&tag=&reviewed=&archived=&q=&folder=&limit=&offset=,GET/PATCH/DELETE /api/items/{id}GET /api/items/changes?since=&limit=→{items, deleted, cursor, more, reset}: what changed in the stash after a cursor, for apps that keep their own copy.itemsare the ones added or changed (any edit, tag, link, picture or saved page counts) as they are now,deletedthe ids removed. Start withsince=0, or withchanges_cursorfromGET /api/stash/summaryread before loading your lists (read after, it could skip a change made while they loaded); send backcursornext time, ask again straight away whilemoreis true (500 at most per call), and reload everything whenresetis true (the server's database was restored from a backup).GET /api/items/{id}/page(the saved readable copy:{url, status, title, html, fetchedAt, error}),POST /api/items/{id}/page/refreshGET/POST /api/stash/folders,PATCH/DELETE /api/stash/folders/{id},POST /api/stash/folders/reorderGET/POST /api/stash/lists,PATCH/DELETE /api/stash/lists/{id}(smart lists)GET/POST /api/stash/rules,DELETE /api/stash/rules/{id},POST /api/stash/rules/applyGET/PUT/DELETE /api/mail(the connected mailbox; the password is write-only),POST /api/mail/test,POST /api/mail/check
Syncing feeds. A client that keeps its own copy shouldn't page through a list that changes while someone reads it. Ask for ids, work out the difference, then fetch only what's missing:
GET /api/articles/ids?scope=&id=&state=unread|starred|all&since_id=&limit=→{ids, max_id}. Ids are small enough to fetch the whole unread set at once (10,000 max).max_idis what to send back assince_idnext time.POST /api/articles/contentswith{ids}(1,000 max) → the articles behind those ids, with content.- Full text. Many feeds only send a summary.
POST /api/articles/{id}/full-textfetches the article's own page and returns{status: "ready" | "failed", content, error}(?refresh=truefetches again; 409 when the server hasPAGE_CAPTUREoff). A feed withfull_text: true(PATCH /api/feeds/{id}) has it fetched in the background for every new unread article. Articles carry it asfull_content(null until fetched) next to the feed'scontent. A feed withopen_website: trueshould open its articles'urlinstead of showing them. POST /api/articles/markwith{ids, read}andPOST /api/articles/starwith{ids, starred}send up changes in one go, which is what a client queues while it's offline.
Read state lives on the server, so every device sees the same thing; there's no peer-to-peer syncing. Two things worth doing in a client: keep read/starred state in its own table (it can learn a state for an article it hasn't downloaded, and the state should outlive the article), and keep unsent changes in a bounded, chunked queue that wins over the server's version when they disagree.
GET /api/tags→[{name, count}], most used first;GET /api/healthPOST /api/tags/rename{name, newName}→{name, items, merged}: renames a tag on every item; naming an existing tag merges the two. Smart lists and rules that use the old name follow it.POST /api/tags/merge{names, into}→{name, items, merged}: replaces several tags with one on every item, in one go;intocan be one of them or a new name.POST /api/tags/delete{names}→{items, tags, lists, rules}: takes tags off every item, archived ones too. Smart lists that filtered only on them and rules that only added them are deleted; others stop using them.POST /api/feedsalso takesr/<name>(or a subreddit's address) asurland follows that subreddit's feed.GET /api/discover→{packs: [{id, name, description, feeds: [{title, url, site, about, cadence, followed}]}]}: starter feeds shipped with FeedStash (app/feeds/discover.json); follow one withPOST /api/feeds(folder_nameset to the pack's name puts it in a folder of its own).GET /api/feeds/{id}/stats→{window_days, posted, posts_per_week, read, starred, saved}: what a feed posted in the last four weeks (less with a short read retention) and how much of it was read, starred or saved to the stash.GET /api/muted-words→[{id, phrase, title_only, folder_id, feed_id}];POST /api/muted-words{phrase, title_only?, folder_id?, feed_id?, mark_unread_read?}→{word, marked, batch}(title_onlydefaults to true; set one offolder_idorfeed_idto mute only there;mark_unread_readalso marks the unread articles it matches as read, undone withPOST /api/mark-read/undo{scope: "all", batch});DELETE /api/muted-words/{id}. Muted articles are stored already read, so syncing clients need nothing extra.GET/POST /api/tokens,PATCH /api/tokens/{id}{clientName}(rename),DELETE /api/tokens/{id}GET /uploads/{name}
Every item has a links list of {id, url, label}, and url mirrors the first link, so clients that only know one URL keep working. links can be sent as a list of URLs or {url, label} objects, as a JSON string of either (for multipart), or one URL per line. PATCH with links replaces the whole list; PATCH with only url swaps the first link and keeps the rest.
pageHtml is the page as the client sees it. Sites that turn servers away (or need you to be signed in) can't be fetched by FeedStash, so the browser extension sends the open tab's HTML and the readable copy is made from that. Rules run on everything posted to /api/items.
Items with a web address also have a preview: {status, title, description, image, siteName, hasCopy, fetchedAt, error}, where status is pending or working while the page is being saved, then ready, skipped (not a web page) or failed. It's null for items without an address.
API tokens can't manage accounts or tokens (list, create or revoke them); that needs a signed-in browser session, so a token that leaks can't give itself new credentials.
.github/workflows/docker-publish.yml runs the tests, builds the image, checks that it starts, drops root and serves the setup page, then pushes linux/amd64 and linux/arm64 builds to Docker Hub as liftbridgelabs/feedstash. It runs when you push a version tag, or by hand from the Actions tab.
- Push this repository to GitHub.
- Add the repository secrets
DOCKERHUB_USERNAMEandDOCKERHUB_TOKEN(a Docker Hub access token with read and write access). - Tag a release:
git tag v0.1.0 && git push origin v0.1.0. That publishes0.1.0,0.1andlatest.
To use another image name, change IMAGE in the workflow, image: in docker-compose.yml, and the unRAID template.
Dependencies point one way: web → services → feeds → db. Lower layers never import higher ones.
app/
__main__.py python -m app: runs the server with host/port from the settings
main.py create_app(): wires settings, database, image store, scheduler, middleware and routers
settings.py typed, validated configuration (pydantic-settings)
cli.py admin commands: accounts, passwords, backups
passwords.py scrypt password hashing
errors.py domain errors (InvalidInput, NotFound, Conflict); the web layer maps them to 400/404/409
images.py uploaded images: validated by content, random names
opml.py OPML parse/build, pure functions
greader.py Google Reader API names and ids, pure functions
markers.py $Folder and #tag markers in email, pure functions
crypto.py encrypting the few secrets FeedStash must reuse (a mailbox password)
db/
database.py connections; one transaction per unit of work
migrations.py versioned schema changes, tracked in PRAGMA user_version
models.py plain dataclasses returned by the repositories
repositories/ all SQL lives here: users, folders, feeds, articles, items, pages, search (FTS5), tokens,
and for organizing the stash: stash_folders, smart_lists, stash_rules
feeds/ parser (no I/O), fetcher (HTTP only), ingest (storage), scheduler (background refresh),
to_stash (saving an article to the stash, by hand or automatically)
pages/ fetching a saved link's page and extracting its preview and readable copy (trafilatura)
mail/ the IMAP client and turning a message into a stash item's parts
services/
accounts.py first-run setup, checking passwords, admin changes to accounts
bookmarks.py importing saved links in bulk
subscriptions.py following a feed, importing OPML
stash.py capturing items, saving feed articles to the stash
pages.py background worker that saves the pages behind stash items
mail.py background worker that checks connected mailboxes
web/
api/ JSON routers: tree, articles, folders, feeds, opml, imports, stash, stash_organize, mail,
tokens, accounts, sign_in, logs, and greader (the Google Reader API)
auth.py sign-in (passwords, Google, OIDC), API tokens
sso.py the Google and OIDC providers (server settings + Settings → Sign-in) and the allowed list
ratelimit.py pauses password guessing
...
static/ the single-page UI (plain JavaScript modules, no build step)
clients/ browser extension and email worker
deploy/unraid/ unRAID container template
docker-entrypoint.sh fixes /data ownership, then runs the app as PUID:PGID
The background refresher, page saver and mail checker run inside the web process, so run a single process (the Docker image does).
pip install -r requirements-dev.txt
pytest # unit tests + API tests
READER_E2E_BROWSER="/path/to/chrome" pytest # also run the browser tests (Chrome or Edge)tests/unit/: each layer directly (settings, parser, fetcher, migrations, repositories, passwords, accounts, images).tests/test_*.py: the real server on a free port with a fresh database.test_auth.pycovers setup, passwords, lockout, accounts and a full OIDC sign-in against a local test provider;test_client_compat.pysends requests shaped exactly like the extension, email worker and phone apps.tests/e2e/: a headless browser (first-run setup, scroll-to-mark-read, drag and drop, auto-refresh, capturing and reviewing).
Security notes
- Passwords are hashed with scrypt. Sessions are signed cookies (
SameSite=Lax,Secureover https), started fresh on every sign-in. Changing or resetting a password signs that account out of every other browser, and Settings → You → Sign out other browsers does the same on demand (apps with a token stay connected; revoke tokens under Connected apps). - The server's mailbox connection checks the mail server's certificate, and mailbox settings can only be changed from the web app; changing the mail server asks for the password again.
- Credentials passed in a URL (
?token=, the Reader API'sClientLogin) are hidden in the access log. - Feed HTML is sanitized twice (server and browser), and a strict Content-Security-Policy blocks all scripts except the app's own. Stash text is always shown as plain text.
- Browser requests need a CSRF header; API clients use bearer tokens, which browsers never send on their own.
- Uploaded image URLs are not behind sign-in, so the random file name is what protects them. SVG and other non-image uploads are rejected.
- Any signed-in user can make the server fetch any http(s) URL when adding a feed. Only give accounts to people you trust.
FeedStash is free software under the GNU Affero General Public License v3.0. You can run it, change it and share it. If you run a changed version as a service for other people, the license asks you to offer them its source. The license covers the code; the FeedStash name and logo are trademarks, and TRADEMARKS.md says how they can be used.



