Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

inkwell

inkwell is a self-hosted RSS/Atom reader that serves articles as static HTML tuned for the built-in browser on a Kindle. Background jobs pre-extract every article and transcode every embedded image, so a tap on the device fetches ready-to-render bytes from local disk.

inkwell home screen on a Kindle

Documentation

  • Installation — build from source, run under Docker, first configuration.
  • Self-hosting — docker-compose, reverse proxy, admin access control, backups, upgrades.
  • Reading — the listing, article, and read-later views, and their behaviour on a Kindle.
  • Admin — add and remove feeds and groups, import OPML.
  • Authenticating your e-reader — sign a new Kindle in through the auth gateway without typing on the device.
  • Configuration reference — every YAML field and environment variable, with defaults.

Installation

inkwell ships as a single Rust binary and a Docker image built from the same source. Pick whichever fits the target host.

Docker

Requires a working Docker daemon.

docker build -t inkwell:latest .
docker run --rm -p 8080:8080 \
  -v "$PWD/config.yaml:/app/config.yaml:ro" \
  -v inkwell-data:/data \
  inkwell:latest

The image listens on port 8080 and stores the SQLite cache (plus, if enabled, the Gemini TLS material) in /data. Mount a named volume there to persist state across container recreation. The bundled config is baked in at /app/config.yaml; the -v above overrides it with a config on the host.

From source

Requires Rust 1.80 or newer.

git clone https://codeberg.org/kendal/inkwell.git
cd inkwell
cargo build --release

The binary is written to ./target/release/inkwell. It takes a single positional argument — the path to a YAML config file.

cp config.example.yaml config.yaml
./target/release/inkwell config.yaml

Expected output:

INFO scheduler armed — refresh: '@every 10m', purge: '0 3 * * *', article_ttl_days: 30
INFO listening on http://0.0.0.0:5050

On a Debian or Ubuntu build host, pkg-config and ca-certificates are required at build time; both are installed automatically by the shipped Dockerfile. No system libraries are required at runtime.

First configuration

Any usable config file needs at least an rss: block listing one or more feeds. The shipped config.example.yaml covers the common shape:

rss:
  groups:
    - name: "Tech"
      feeds:
        - https://lobste.rs/rss
        - https://news.ycombinator.com/rss

That block is read only on the first launch to seed the SQLite database. Once feeds and groups have been edited through the /admin page, the database is the source of truth and the config's rss: section is effectively read-only documentation.

For every other field, see the configuration reference.

Next

  • Self-hosting — running inkwell behind a reverse proxy, with backups and upgrades.
  • Reading — what the Kindle-facing UI looks like.

Self-hosting

Recipes for running inkwell as a long-lived service — docker-compose, a reverse proxy for TLS, admin access control, backups, and upgrades.

docker-compose

services:
  inkwell:
    image: inkwell:latest
    build: .
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - inkwell-data:/data

volumes:
  inkwell-data:

Place config.yaml next to the compose file (start from config.example.yaml), then:

docker compose up -d

The reader is reachable at http://<host>:8080/. The inkwell-data volume holds the article cache, image cache, bookmarks, and admin-edited feed list, and persists across container recreation.

Reverse proxy

To serve over HTTPS, place a reverse proxy in front of the container.

Caddy

inkwell.example.com {
    reverse_proxy localhost:8080
}

Caddy provisions and renews TLS certificates automatically.

nginx

server {
    listen 443 ssl http2;
    server_name inkwell.example.com;

    ssl_certificate     /etc/letsencrypt/live/inkwell.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/inkwell.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Admin access control

The /admin route is unauthenticated. When the reader is exposed beyond a trusted network, gate /admin/* at the reverse proxy — HTTP Basic auth in nginx or Caddy is enough, or use an external identity provider such as authelia or Authentik.

To sign a new Kindle in without typing a password on the device, see authenticating your e-reader.

Backups

All persistent state lives in the inkwell-data volume. Back it up with a periodic SQLite .backup to a path inside the volume, then copy the resulting file off-host:

docker compose exec inkwell sqlite3 /data/reader_cache.sqlite \
  ".backup '/data/backup-$(date +%Y%m%d).sqlite'"

Upgrades

docker compose pull   # or: docker compose build --pull
docker compose up -d

Schema migrations run on startup.

Reading

The reader is served as static HTML, with no JavaScript. Every page works identically on a Kindle's built-in browser and on a desktop.

Views

ViewPathContents
All stories/Every subscribed feed merged, newest first.
Feeds/feedsOne feed per row; tap a feed to see just its stream.
Groups/groupsOne group per row; tap a group to see its feeds merged.
Article/item/{id}The extracted article for one entry.
Read later/read-laterEvery bookmarked article, newest bookmark first.

Every listing row shows the title, the source host, and a bookmark icon. Tapping the title opens the article; tapping the icon toggles the bookmark.

Listings paginate at twenty entries per page. Previous and Next links appear at the bottom when there is more than one page.

Bookmarks

The bookmark icon next to every article toggles the "read later" state. Toggling it does not change the page: the row's icon flips from outlined to filled (or vice versa) and the browser scrolls back to that row.

/read-later lists every bookmarked article. A bookmark keeps its title and URL alongside the article id, so a saved article stays visible even after its source feed rolls the entry off. Tapping a bookmarked article whose body has been purged re-extracts it on demand.

Bookmarked articles are exempt from the purge job. Removing the bookmark makes the article eligible for purge on the next sweep.

Images

Every image in a rendered article is downscaled to fit within 1200×1600 pixels, flattened to a solid background, and re-encoded as JPEG at ≤150 KiB. Results are cached; the same image renders from local disk on subsequent views.

Images the transcoder cannot fetch or decode are replaced with the image's alt text.

Non-HTML articles

When a feed entry points to a PDF, an image, a video, or another binary file, the article page renders a short notice and a link to the original URL. The Kindle's built-in viewer opens the link natively.

Detection uses the response's Content-Type header, falling back to the URL's path extension (.pdf, etc.).

When extraction fails

Some sites refuse HTML to server-side requests, either through paywall gates, JavaScript challenges, or IP-based bot detection. When the extractor cannot fetch the article, the article page renders a short notice and a link to the original. Tapping the link opens the article in the Kindle's browser, which fetches from the device's IP and often succeeds where the server-side extractor does not.

Failed extractions are not cached; a later tap retries from scratch.

Density and theme

Two links in the top nav toggle layout preferences:

  • Density — default or compact (more rows per screen).
  • Theme — light or dark.

Both are sticky per browser via a cookie. The initial state for a new visitor comes from view.compact_default and view.dark_default in the configuration file.

For a one-shot override without touching the cookie, append a query parameter:

/?compact=1     # compact for this request
/?compact=0     # comfortable for this request
/?theme=dark    # dark for this request
/?theme=light   # light for this request

Admin

The /admin page manages the subscribed feeds and groups. Changes take effect immediately; no restart is required.

The configuration file's rss: block seeds the database on the first launch only. After any admin edit, the database is the source of truth and the seed block is ignored on subsequent restarts.

Add a group

Type the group name into the toolbar's New group name field and submit. Group names are unique; adding an existing name returns an error.

Remove a group

Tap the × icon next to the group's heading. The confirmation prompt lists the group name; confirming deletes the group and its feed subscriptions. Any articles those feeds had cached stay in the cache and continue to appear under any other group still subscribed to the same feed.

Add a feed

Every group has an Add feed form under its heading. Typing into the input shows autocomplete suggestions from the configured feed providers; the default provider takes a site URL and returns any RSS, Atom, or JSON-feed links advertised on the page via <link rel="alternate">.

Tap a suggestion to fill in the URL, then submit.

Only http:// and https:// URLs are accepted.

Remove a feed

Tap the × icon next to the feed URL. The confirmation prompt lists the URL; confirming removes only that group's subscription. If the same feed is subscribed under multiple groups, the others are untouched.

Import OPML

The Import OPML form in the toolbar accepts an OPML 1.0 or 2.0 export from another reader. Select a file and submit.

Import rules:

  • Groups merge by name. A category whose name matches an existing inkwell group adds its feeds to that group. New names create new groups.
  • Feeds deduplicate. A URL already subscribed in the target group is skipped, not added twice. Re-importing the same file is a no-op.
  • Nested categories use the nearest label; a feed under <outline text="Tech"><outline text="Programming"><feed/></outline></outline> lands in Programming.
  • Top-level (ungrouped) feeds go into an auto-created group named Uncategorized.
  • Unsafe schemes are skipped. Any xmlUrl that isn't http:// or https:// is counted as invalid.

Uploads are capped at 1 MiB.

After import, a flash message reports the outcome:

Imported 47 feed(s) into 3 new group(s); 2 duplicate(s), 1 invalid skipped.
CountMeaning
ImportedFeeds added to the database.
New groupsGroups that didn't exist before this import.
DuplicatesFeed URLs already subscribed in their target group.
InvalidxmlUrl entries that failed the scheme allow-list.

Exporting from other readers

ReaderExport path
NetNewsWireFile → Export Subscriptions → OPML.
FeedlySettings → OPML → Export your Feedly OPML.
InoreaderPreferences → Folders and tags → Export → OPML.
FreshRSS⚙ → Subscription management → Export.
MinifluxSettings → Export.
Tiny Tiny RSSPreferences → Feeds → OPML → Export.

The resulting file imports into inkwell without modification.

Restricting access

The admin page has no built-in authentication. When the reader is exposed beyond a trusted network, gate /admin at the reverse proxy or behind an identity provider. See self-hosting.

Authenticating your e-reader

inkwell-pair is a small companion service that signs a device into the auth gateway by cookie, so a new Kindle doesn't have to type a password on the device.

The flow: from an authenticated browser, generate a 6-digit code; on the new device, open /token/<code> once; the sidecar sets the session cookie and redirects to the reader. The device then behaves as authenticated for the cookie's lifetime.

The sidecar does not authenticate anyone itself. It sets the cookie that an external gateway (authelia, nginx forward-auth, Authentik, Custom) already treats as a valid session.

Routes

MethodPathEffect
GET/generate-tokenMints a 6-digit code, stores it with the configured TTL, renders it as a page.
GET/token/<code>Validates the code; on success sets the cookie and 303-redirects to PAIR_REDIRECT_URL; on failure returns 404.

/generate-token is the route the reverse proxy gates behind the auth gateway. /token/<code> is the route it leaves reachable to unauthenticated devices — that's the whole point.

The token store is in-memory. A restart drops any unredeemed codes; they're short-lived enough that regenerating is trivial.

Configuration

Every knob is an environment variable. Only PAIR_REDIRECT_URL is required.

VariableDefaultEffect
PAIR_REDIRECT_URLrequiredWhere /token/<code> redirects on success.
PAIR_PORT3000HTTP listen port.
PAIR_BIND0.0.0.0Bind interface. Use 127.0.0.1 to restrict to the local proxy.
PAIR_TOKEN_TTL_SECS300Lifetime of a freshly minted code (5 minutes).
PAIR_COOKIE_NAMEauthelia_sessionCookie name.
PAIR_COOKIE_VALUEvalidCookie value. Set to whatever the auth gateway treats as a valid session token.
PAIR_COOKIE_DOMAIN(unset)Cookie Domain. Use .example.com to cover subdomains.
PAIR_COOKIE_PATH/Cookie Path.
PAIR_COOKIE_MAX_AGE_SECS2592000Cookie Max-Age (30 days).
PAIR_COOKIE_SECUREtrueSecure flag. Disable only when running over plain HTTP inside a trusted LAN.
PAIR_COOKIE_HTTP_ONLYtrueHttpOnly flag.
PAIR_COOKIE_SAME_SITELaxSameSite value (Lax, Strict, or None).
RUST_LOGinfoTracing filter.

Docker

docker build -t inkwell-pair:latest -f pair/Dockerfile .
docker run --rm -p 3000:3000 \
  -e PAIR_REDIRECT_URL=https://inkwell.example.com/ \
  -e PAIR_COOKIE_DOMAIN=.example.com \
  inkwell-pair:latest

The -f pair/Dockerfile selects the sidecar's Dockerfile while keeping the build context at the repo root, so the builder can see the workspace Cargo.toml and Cargo.lock.

docker-compose

Run the sidecar alongside the reader:

services:
  inkwell:
    image: inkwell:latest
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - ./config.yaml:/app/config.yaml:ro
      - inkwell-data:/data

  inkwell-pair:
    image: inkwell-pair:latest
    build:
      context: .
      dockerfile: pair/Dockerfile
    restart: unless-stopped
    ports:
      - "3000:3000"
    environment:
      PAIR_REDIRECT_URL: https://inkwell.example.com/
      PAIR_COOKIE_DOMAIN: .example.com
      PAIR_COOKIE_NAME: authelia_session
      PAIR_COOKIE_VALUE: ${PAIR_COOKIE_VALUE}

volumes:
  inkwell-data:

Keep PAIR_COOKIE_VALUE out of the compose file; pass it via .env or a secret manager.

Reverse proxy

The proxy in front of both services splits behaviour by host and by path:

  • inkwell.example.com/* — gated. Every request goes through the auth gateway.
  • pair.example.com/generate-token — gated. Only an operator with a browser session should mint codes.
  • pair.example.com/token/<code> — bypassed. This is the entry point for a device without a session; gating it breaks the flow.

The examples below sketch the shape; TLS setup, header forwarding, and other proxy boilerplate are omitted. Each assumes authelia at 127.0.0.1:9091, the reader at 127.0.0.1:8080, and the sidecar at 127.0.0.1:3000.

Caddy

inkwell.example.com {
    forward_auth 127.0.0.1:9091 {
        uri /api/authz/forward-auth
    }
    reverse_proxy 127.0.0.1:8080
}

pair.example.com {
    @redeem path_regexp ^/token/[0-9]{6}$
    handle @redeem {
        reverse_proxy 127.0.0.1:3000
    }
    handle {
        forward_auth 127.0.0.1:9091 {
            uri /api/authz/forward-auth
        }
        reverse_proxy 127.0.0.1:3000
    }
}

nginx

server {
    server_name inkwell.example.com;
    auth_request /_auth;
    location / { proxy_pass http://127.0.0.1:8080; }
    location = /_auth { internal; proxy_pass http://127.0.0.1:9091/api/verify; }
}

server {
    server_name pair.example.com;
    location ~ ^/token/[0-9]{6}$ {
        proxy_pass http://127.0.0.1:3000;
    }
    location / {
        auth_request /_auth;
        proxy_pass http://127.0.0.1:3000;
    }
    location = /_auth { internal; proxy_pass http://127.0.0.1:9091/api/verify; }
}

Traefik

services:
  inkwell:
    labels:
      - "traefik.http.routers.inkwell.rule=Host(`inkwell.example.com`)"
      - "traefik.http.routers.inkwell.middlewares=authelia@docker"

  inkwell-pair:
    labels:
      # Redemption bypass — higher priority so it matches first.
      - "traefik.http.routers.pair-redeem.rule=Host(`pair.example.com`) && PathRegexp(`^/token/[0-9]{6}$`)"
      - "traefik.http.routers.pair-redeem.priority=100"
      # Everything else on pair.example.com is gated.
      - "traefik.http.routers.pair-mint.rule=Host(`pair.example.com`)"
      - "traefik.http.routers.pair-mint.middlewares=authelia@docker"

Authelia access control

# authelia.yml
access_control:
  default_policy: deny
  rules:
    - domain: inkwell.example.com
      policy: one_factor
    - domain: pair.example.com
      resources: ['^/generate-token$']
      policy: one_factor
    - domain: pair.example.com
      resources: ['^/token/[0-9]{6}$']
      policy: bypass

Set PAIR_COOKIE_NAME and PAIR_COOKIE_VALUE to the values the auth setup treats as "this device is trusted". For stock authelia, integration is more involved than a fixed cookie value can support; the sidecar is most useful with custom forward-auth shims that accept "session exists" as proof.

Configuration reference

inkwell reads a single YAML file at startup, passed as the only positional argument. Runtime knobs — port, cache path, HTTP timeout, log filter — come from environment variables.

A complete config has five top-level blocks. Only rss: is required:

rss: …          # required
scheduler: …    # optional
view: …         # optional
gemini: …       # optional
feed_search: …  # optional, defaults to link_auto_discovery

Fields at a glance

PathRequiredDefault
rss.groups[].nameyes
rss.groups[].feedsyes
scheduler.refreshno
scheduler.purgeno
scheduler.article_ttl_daysno30
scheduler.log_fileno./inkwell.log
view.compact_defaultnofalse
view.dark_defaultnofalse
gemini.bindif block0.0.0.0:1965
gemini.cert_pemif block
gemini.key_pemif block
gemini.hostnamesif block
feed_search.providersno[{kind: link_auto_discovery}]

Environment variables: PORT, CACHE_DB, FEED_TTL, HTTP_TIMEOUT, RUST_LOG.

rss (required)

The seed feed list. Read once on the first startup to populate the SQLite store; after that, edit feeds and groups via /admin.

rss:
  groups:
    - name: "Hobbies & tech"
      feeds:
        - https://news.ycombinator.com/rss
        - https://lobste.rs/rss
    - name: "World"
      feeds:
        - https://feeds.bbci.co.uk/news/world/rss.xml

rss.groups[].name

Label shown in the Groups view.

rss.groups[].feeds

Feed URLs. A URL listed in multiple groups still resolves to one cache row.

scheduler (optional)

Background jobs. Without this block, feeds refresh only on demand and articles are never purged.

scheduler:
  refresh: "@every 10m"
  purge: "0 3 * * *"
  article_ttl_days: 30
  log_file: ./inkwell.log

scheduler.refresh

How often to fetch every feed and pre-extract new articles into the cache. Accepts 5-field cron (min hr dom mon dow), 6-field with leading seconds, or @every <duration>.

scheduler.purge

How often to sweep old articles and cached images out of the cache. Same format as refresh. Bookmarked articles are exempt.

scheduler.article_ttl_days

Maximum age (in days) an article stays in the cache before the purge job may delete it. Default 30.

scheduler.log_file

Path to the rolling log file. Default ./inkwell.log.

view (optional)

Default UI preferences for a new visitor. Both are also togglable per-session via the top nav.

view:
  compact_default: false
  dark_default: false

view.compact_default

If true, compact density is the initial state.

view.dark_default

If true, dark theme is the initial state.

gemini (optional)

When present, inkwell starts a parallel Gemini server serving the same listings and articles as gemtext over TLS. The cert and key are generated on first launch if the files don't exist. Gemini clients TOFU-pin certs, so keep them stable across restarts.

gemini:
  bind: "0.0.0.0:1965"
  cert_pem: "./gemini.cert.pem"
  key_pem: "./gemini.key.pem"
  hostnames:
    - localhost
    - inkwell.example.com

gemini.bind

host:port to listen on. Gemini's default port is 1965.

gemini.cert_pem

Path to the TLS certificate. Generated on first launch if missing.

gemini.key_pem

Path to the TLS private key. Generated alongside the cert.

gemini.hostnames

Subject Alternative Names baked into a freshly generated certificate. List every hostname clients might use to reach the server.

feed_search (optional)

Providers powering the autocomplete on the admin UI's Add feed inputs.

feed_search:
  providers:
    - kind: link_auto_discovery

Every hop's host is resolved and rejected if it sits in a private/loopback/link-local range. Redirects are followed manually so a public→internal redirect can't smuggle past the check.

Built-in provider. Fetches the user's URL and scrapes <link rel="alternate"> tags advertising RSS / Atom / JSON-feed payloads. The default, and the recommended one.

feedsearch

feedsearch.dev passthrough. Currently fronted by a Cloudflare challenge that blocks server-side requests; off by default, kept for environments that can reach it.

Environment variables

PORT

HTTP listen port. Default 5050 from source builds, 8080 in the Docker image.

CACHE_DB

Path to the SQLite cache file. Default ./reader_cache.sqlite; the Docker image overrides this to /data/reader_cache.sqlite so the cache persists in the mounted volume.

FEED_TTL

Per-feed in-memory cache TTL in seconds. Lower = more refetches, higher = staler reads. Default 600.

HTTP_TIMEOUT

Outbound HTTP request timeout in seconds, covering both feed fetches and article extraction. Default 15.

RUST_LOG

Standard tracing-subscriber filter expression. Default info.

RUST_LOG=inkwell=debug,rusqlite=warn