Bookmarks, feeds and books on one VPS, and how little I compared

Why my reading stack is Karakeep, FreshRSS and Kavita, what the choice really rested on, and the parts I still haven't tested.

Bookmarks, feeds and books on one VPS, and how little I compared

I didn't choose Karakeep. I chose "the thing for bookmarks". That is close to the exact wording of the message I sent, and it's the honest place to start, because a lot of self-hosting write-ups pretend there was a careful evaluation matrix behind every container. For my reading stack there mostly wasn't.

It was a Wednesday morning in October. I had asked my AI coding assistant to look at what I already ran on the VPS and suggest what was missing. The stack at that point was Traefik, Nextcloud, Immich, a couple of git hosts, four note-taking apps (yes, four, I know) and NocoBase as the place where all my small trackers live. The list that came back had three lines in a section called "Fits things you're already doing":

- Karakeep or Linkwarden: bookmarks and read-later, with AI tagging if you point it at an API.
- Audiobookshelf or Kavita: books and audiobooks.
- Miniflux or FreshRSS: an RSS reader.

Each line offered two names. I answered all three in one go, between other things: the bookmark thing sounded good, my books were already backed up on Google Drive and I'd like something that fetched them from there and reminded me about the ones I'd started, and RSS sounded great except that I didn't really know which topics I'd follow, so please just set it up and wire it into NocoBase. I also wrote the sentence that ended up shaping everything else: "for all apps there should be minimal config and maintenance effort."

So the comparisons never really happened. Below is what decided each one, what the setup looks like, and the bits I still haven't run for real.

What I said no to in the same message

The reading apps make more sense next to the things I turned down in that same reply, because the same rule decided all of them.

The list opened with a password manager, Vaultwarden. I already pay for a hosted one and I'm happy with it, and running my own vault on the same box as everything else would add the one service I really can't afford to lose. No. Next came off-site backups with Kopia or Restic. My Nextcloud data already goes to Backblaze, so that one was covered for the thing I care most about. A budgeting app, Actual Budget or Firefly III: I'd run Firefly before, and it felt like overkill for one person. The finance tracker I'd built in NocoBase does what I need. n8n for automation: I'd wanted to host it for a while, but couldn't name a single thing I'd use it for that NocoBase workflows don't already do. A VPN for private access to services: I didn't see why I'd need one at the time. (By that evening I was running Tailscale for a private ChatOps channel, so that aged badly.)

What I said yes to was Uptime Kuma, ntfy, an SSO layer, Dozzle for logs, and the three reading apps. And every yes came with the same condition, which I wrote at the end of the message in lowercase because I was typing on autopilot: "for all apps there should be minimal config and maintenance effort."

That condition explains most of what follows. It's why FreshRSS uses SQLite instead of a database container. It's why secrets come from a setup.sh that runs once instead of a hand-edited .env. It's why nothing in the reading stack sits behind SSO. And it's why I accepted three apps I hadn't compared: comparing would have been effort, and I'd just asked for less of it.

What actually decided each pick

Karakeep over Linkwarden. Nothing decided it, if I'm honest. The assistant built Karakeep, I didn't object, and I have never opened Linkwarden. Wallabag never came up at all. If you came here for a Karakeep vs Linkwarden comparison, I can't help, and I'd rather say so than make one up afterwards. What I can tell you is what Karakeep needed once it was picked, and that it has official iOS and Android apps with a share-sheet target. That was the one feature I checked, and only because I asked "does it have a mobile app?" a few hours after deploying it. Even then the answer came from search results, not from Karakeep's own docs, and the assistant said as much.

FreshRSS over Miniflux. The reasoning I got was short: one container, SQLite, a mobile-app API. That fits "minimal maintenance" well. Nothing in my notes compares it to Miniflux's requirements, so I won't either. The mobile-app API is the part that ended up mattering, though for a reason I didn't expect: NocoBase uses it to pull my unread articles, which is its own story.

Kavita over Audiobookshelf. My request was about PDFs and EPUBs sitting in a Drive folder. Kavita reads EPUB and PDF. It does not read MOBI or AZW3, which matters a little because some of my older files are in those formats, and the sync only ever copies the two types it can open. Audiobookshelf wasn't looked at beyond that one line in the list.

That's the whole decision record. Three apps chosen in about the time it takes to read a paragraph, and all the actual effort went into what came afterwards.

Karakeep, set up

Karakeep is the heaviest of the three. The web app needs a headless Chrome to fetch and archive pages, and Meilisearch for full-text search. My compose file follows the upstream one, with my own conventions on top: container names, the shared frontend network that Traefik watches, and a private network for the two helpers.

services:
  web:
    image: ghcr.io/karakeep-app/karakeep:${KARAKEEP_VERSION:-release}
    container_name: karakeep_app
    restart: unless-stopped
    networks: [frontend, internal]
    env_file: [.env]
    environment:
      - MEILI_ADDR=http://karakeep_meili:7700
      - BROWSER_WEB_URL=http://karakeep_chrome:9222
      - DATA_DIR=/data # DON'T CHANGE THIS
      - DISABLE_SIGNUPS=${KARAKEEP_DISABLE_SIGNUPS:-false}
      - DISABLE_NEW_RELEASE_CHECK=true
      # - OPENAI_API_KEY=${OPENAI_API_KEY:-}
    volumes:
      - ./data:/data
    depends_on: [meilisearch, chrome]
    labels:
      - traefik.enable=true
      - traefik.http.routers.karakeep.rule=Host(`${KARAKEEP_HOST:-karakeep.example.com}`)
      - traefik.http.routers.karakeep.entrypoints=websecure
      - traefik.http.routers.karakeep.middlewares=default-security-headers@file,remove_identity@file
      - traefik.http.services.karakeep.loadbalancer.server.port=3000
      - traefik.docker.network=frontend

  chrome:
    image: ghcr.io/karakeep-app/karakeep-chrome:release
    container_name: karakeep_chrome
    restart: unless-stopped
    init: true
    networks: [internal]
    command:
      - --disable-gpu
      - --disable-dev-shm-usage
      - --hide-scrollbars
      - --disable-blink-features=AutomationControlled
      - --window-size=1440,900

  meilisearch:
    image: getmeili/meilisearch:v1.41.0
    container_name: karakeep_meili
    restart: unless-stopped
    networks: [internal]
    env_file: [.env]
    environment:
      - MEILI_NO_ANALYTICS=true
    volumes:
      - ./meili:/meili_data

networks:
  frontend:
    external: true
  internal: {}

Two things in there I'd point out to anyone copying it.

The internal network is a plain bridge, not internal: true. It looks like an oversight and it isn't. A Docker network marked internal: true has no route out, and the Chrome container's entire job is to go out to the internet and render the page you just saved. The private network is there so Chrome and Meilisearch aren't reachable from Traefik's network. It isn't meant to cut them off from the outside.

It's also a private network for this stack, not the shared backend network some of my older apps sit on. On a shared network, two stacks that both have a service called meilisearch or chrome can resolve each other's containers through Docker DNS, and you get a very confusing afternoon. A private network per stack costs nothing.

The secrets come from a tiny setup.sh that refuses to run twice:

#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
[ -e .env ] && { echo ".env already exists — nothing to do"; exit 1; }
rand() { head -c 512 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c "$1"; }
mkdir -p data meili
umask 077
cat > .env <<ENV
KARAKEEP_HOST=karakeep.example.com
KARAKEEP_VERSION=release
NEXTAUTH_SECRET=$(rand 48)
MEILI_MASTER_KEY=$(rand 48)
NEXTAUTH_URL=https://karakeep.example.com
KARAKEEP_DISABLE_SIGNUPS=false
ENV

Signups start enabled, because otherwise you can't create your own account. The procedure is: docker compose up -d, register, flip KARAKEEP_DISABLE_SIGNUPS=true in .env, docker compose up -d again. It's a two-step dance and it's the kind of thing I'll forget if I ever rebuild from scratch, which is why it's written at the top of the compose file as a comment rather than in a README I won't open.

AI tagging stays commented out. Karakeep can call an OpenAI-compatible API to tag what you save. I haven't wanted to send my bookmarks anywhere, and nobody has asked me to.

Deploying it went like every other app in that repo. git pull on the server, ./setup.sh in the app folder, docker compose up -d, then a DNS record for the hostname in Cloudflare, proxied like the rest. Traefik picks the container up from its labels. One thing tripped me up more than once that week: Traefik reads labels when a container is created. Edit a label, run docker compose restart, and nothing changes. The deploy instructions I got for that batch were a loop over every app folder running docker compose up -d, which recreates any container whose configuration changed, plus a warning to expect a brief blip per app. That's the right command. restart only feels right.

Karakeep's port, 3000, is only exposed to Traefik over the frontend network. The compose file publishes no ports on the host.

For quick access there's a Dashy dashboard I use as a start page, and the three apps sit together under a group called "Reading & Media". It's a small thing, but it's the difference between "I should save that link" and actually saving it.

Why none of this sits behind SSO

I run Authelia now, with 2FA, in front of Dozzle and Uptime Kuma. It would be tidy to put everything behind it. I'd already learned with Nextcloud that this breaks the phone app, and I said so up front: no SSO in front of anything a mobile client talks to.

The same rule applies to all three reading apps. Karakeep has phone apps and a browser extension. FreshRSS gets read by mobile RSS clients over its Google Reader API. Kavita gets read by OPDS apps. None of those clients can answer a login page that pops up in front of the real one. So Authelia's README has a line listing what's deliberately left out, and these three are on it with Nextcloud, Immich, Gogs and ntfy. Each app keeps its own login, signups are closed where the app allows it, and that's the protection.

I'm fine with that trade. The alternative is apps that work in a browser and fail silently on the phone, which is where I actually save links and read.

FreshRSS, when you don't know what you want to read

My entire brief for RSS was "I don't really know what topics I would like to follow". The assistant's answer was a starter OPML file. The part I liked: it tested every candidate feed URL before writing it down, so the file holds only feeds that answered with HTTP 200 and something that parsed as a feed. Three candidates failed: a local news site timed out, and two others returned 404. They were dropped rather than left in to rot.

What survived is 36 feeds in seven folders: tech, self-hosting, infra and DevOps, security, local news, one personal-interest feed, and "Releases", which is 19 GitHub release feeds for the apps I run myself. That last folder turned out to be the most useful one. I hadn't asked for an update notifier, but a release feed per app is one: when Kavita or Karakeep ships a version, it lands next to the news. (I added Renovate later for the actual image bumps. The feeds are still how I notice.)

FreshRSS itself configures from environment variables on first start:

    environment:
      CRON_MIN: '3,33'
      # Applied on the very first start only.
      FRESHRSS_INSTALL: |-
        --api-enabled
        --base-url https://${FRESHRSS_HOST:-rss.example.com}
        --default-user admin
        --language en
      FRESHRSS_USER: |-
        --user admin
        --password ${FRESHRSS_PASSWORD:?}
        --api-password ${FRESHRSS_API_PASSWORD:?}
        --language en
    volumes:
      - ./data:/var/www/FreshRSS/data
      - ./extensions:/var/www/FreshRSS/extensions
      - ./feeds.opml:/feeds.opml:ro

"Applied on the very first start only" is upstream's warning, not mine, and it's worth taking literally: change those variables later and nothing happens unless you wipe the data directory. The OPML is mounted read-only, and importing it is one command:

docker compose exec freshrss ./cli/import-for-user.php --user admin --filename /feeds.opml

My first contact with the running instance was a 403 and a Bad Gateway, and neither was a FreshRSS bug. The 403 was me typing my email address into a login form that wanted the username admin. The Bad Gateway was most likely the first start, when the install script runs before Apache is listening. But I couldn't see the real error, because my Traefik setup had an error-pages middleware in its default chain that swapped every 403, 404, 500 and 503 for a generic stock page. Every app router now uses only the security-headers and remove-identity middlewares, so errors come through as the app sent them. I'd recommend that to anyone who has copied an error-pages middleware from a Traefik tutorial: it makes your site look tidier and makes debugging miserable.

The way I found it is a little embarrassing. When I reported the Bad Gateway, I wrote "last time we had this you asked to add a traefik label, do it the same for all our self-hosted apps because it hides the true error". I remembered the fix but not what it was. A grep across every compose file for middlewares= turned it up in the Gogs stack, which had two middleware labels on the same router:

      - traefik.http.routers.gogs.middlewares=default@file
      - traefik.http.routers.gogs.middlewares=default-security-headers@file,remove_identity@file

The second line wins, because a later key replaces an earlier one, so Gogs had been skipping the error-pages chain all along. Every other app used default@file, which is a chain of default-security-headers, remove_identity and error-pages. The error-pages service served its stock page from a third-party server. So any 403, 404, 500 or 503 from any of my apps reached me as somebody else's generic error page, and I had no idea how long I'd been debugging blind. Sixteen routers got the one-line change in one commit. Nextcloud and GitLab live in separate repos and still use the old chain. I didn't touch them, and I haven't gone back yet.

FreshRSS's own healthcheck (cli/health.php every 75 seconds, 60-second start period) was checked against upstream at the same time and left as is. That matters more than it sounds: Traefik drops a container from routing when Docker marks it unhealthy, so a wrong healthcheck turns into a 404 that looks like a routing bug.

The bits I haven't proven

The NocoBase side of all this works, and I've seen it work: unread articles arrive, reading progress comes in from Kavita. Some edges are only tested offline, though, and I want them written down:

  • My ChatOps bot (commands sent from my phone over a private ntfy) has a save <link> command that POSTs to Karakeep's /api/v1/bookmarks with an API key. It's tested against a stub. It has never saved a real link, because I haven't put a Karakeep API key in its config yet.
  • The mobile app setup for Karakeep is what the search results said: enter the server URL, then email and password. I've used it. I haven't read the docs it came from.
  • AI tagging: off, untested, probably staying that way.

How the three fit into a normal day

The flows are simple, which was the point. A link I want to keep, I share from the phone into the Karakeep app. A full-page archive and search come for free, because the Chrome container fetches the page when it's saved. News is designed so I don't have to open FreshRSS at all. A NocoBase workflow pulls my unread items every two hours, and the five newest show up on the NocoBase Home page that I open anyway for bills and reminders. When a headline is worth more, I open the full News page or FreshRSS itself. Books I read in Kavita in a browser tab. My NocoBase reading list has a "Resume in Kavita" link per book that opens the reader at the page where I stopped, and a daily nudge for books I've started and dropped.

From the phone, my ChatOps bot adds a few shortcuts: reading lists what I'm reading right now, rssrefresh makes FreshRSS fetch every feed immediately (it runs ./cli/actualize-user.php --user admin inside the container), and restart karakeep web restarts the one Karakeep service if it misbehaves.

Keeping them updated

Day to day, these apps cost close to nothing. Three stacks, five containers counting Chrome and Meilisearch, no database server beyond Meilisearch and SQLite files. Updates are where any real cost would come from, so they got a written policy (UPDATING.md). It sorts images into four groups:

KindRuleReading-stack members
Stateless or self-migrating, low riskfollow latest, upgrade freelyFreshRSS, rclone
Pinned to a major alreadyonly minor and patch fixes arrivenone here
Has a database schemapin an exact version, back up first, upgrade on purposeKavita, Karakeep
Never bump blindlypinned, and Renovate is told to ignore itMeilisearch

Meilisearch is in the last group because it refuses to start on a different version without a dump and restore. The Renovate config has a rule that disables updates for it, with exactly that reason in the description. If I ever bump it, it'll be on purpose, with a dump in hand.

Then there's the honest part. My own policy says Kavita and Karakeep should be pinned to exact versions, and as of mid-October their compose files still say jvmilazz0/kavita:latest and karakeep:release. That matters beyond tidiness: Renovate can only propose bumps for pinned tags, so for those two apps it's blind. A docker compose pull would move them to whatever upstream published that morning, schema migrations included. Pinning them is a two-line change I keep not making.

When I do upgrade, it's one app at a time with ops/update.sh <app>. The script tags the running images as rollback images, pulls, recreates, waits 30 seconds and reports anything unhealthy. If it goes wrong, ops/rollback.sh <app> brings back the saved images. Before merging an update PR I read the release notes, and for most of these apps that means the "Releases" folder in FreshRSS. The RSS reader is part of how I maintain the other apps.

Where the effort actually went

The real cost of this stack wasn't updates. Kavita needed a Google Drive mirror, a rearranged folder layout and two outages that I caused myself before it showed more than 62 books. Getting reading progress into NocoBase took three rewrites. FreshRSS into NocoBase was the easy one. All of that came out of a decision I spent about thirty seconds on.

If I did it again, I'd keep the apps and change the order of operations. I'd pin every stateful image to an exact version on day one, while the version I'd tested was still the one running, instead of writing a policy afterwards that says I should have. I'd read Karakeep's own docs for the mobile setup rather than trusting a search summary. And I'd spend the hour I didn't spend: install Linkwarden in a scratch folder, save the same ten links in both, and see which one I reach for. Probably nothing would change. But "the assistant built it and I didn't object" isn't a reason I can defend.

Linkwarden is still the one I never looked at.