Expand description
Runtime configuration for the FeatherReader server.
Everything is env-driven with a sane default for every knob, so a bare
./featherreader boots and works — no config file required (the
“trivial to self-host” promise). The environment variables
all share the FEATHERREADER_* prefix:
| Variable | Default | Meaning |
|---|---|---|
FEATHERREADER_BIND | 127.0.0.1:8080 | host:port the HTTP server binds. |
FEATHERREADER_DB | featherreader.db | Path to the SQLite cache file. |
FEATHERREADER_PUBLIC_URL | http://localhost:8080 | Externally-reachable base URL (OAuth callback + client metadata). |
FEATHERREADER_ALLOWED_DIDS | (empty = open) | Comma-separated login allow-list of atproto DIDs. |
FEATHERREADER_POLL_INTERVAL | 3600 (1h) | Default per-feed poll interval, in seconds. At least 1; 0 is refused at startup. |
FEATHERREADER_PUBLICATION_READ_DEADLINE_SECS | 30 | The longest one standard.site publication read may take before it is a failure. Under Fly’s 45 s kill_timeout, so the read in flight at shutdown can finish. Must be at least 1. |
FEATHERREADER_STARTUP_DELAY_SECS | unset | Shortens every background loop’s delay before its FIRST tick (30/45/60/75/90 s, and 5 min for the relay probe). A ceiling: a larger value changes nothing and says so in the log. For dev loops and integration runs; production wants the built-in values. Read in scheduler.rs, listed here because this table is where an operator looks. |
FEATHERREADER_RETENTION_HARD_DAYS | 180 | Absolute ceiling: entries older than this go regardless of starred/unread. The bound that keeps one reader’s pins from filling a shared cache and stalling the poller. 0 removes the ceiling — the ONLY bound on pinned entries, so 0 here means the cache is unbounded. Must be STRICTLY GREATER than the window below, or 0: a ceiling inside the window would delete the rows the window spares, so it cannot be applied, and startup REFUSES the pair rather than silently running unbounded. |
FEATHERREADER_RETENTION_DAYS | 14 | Evict READ, UNSTARRED entries older than this. Starred and unread entries survive this window but not the hard ceiling above. 0 disables this rolling window ONLY; the ceiling still applies. Set BOTH to 0 for no eviction at all. |
FEATHERREADER_PUBLICATION_RETENTION_DAYS | 3650 | Absolute ceiling for entries of a kind the rolling window does not apply to — a standard.site publication. Publications are bounded by COUNT (max_entries_per_feed) instead of by age, because measurement says a 14-day window stores NOTHING from a real publication: the newest documents on three of them were 109 to 241 days old. This is the “not immortal” backstop, not the space bound. 0 disables it. |
FEATHERREADER_PROXY_IMAGES | false | Proxy feed images so reader IPs aren’t leaked to feed hosts. |
FEATHERREADER_TRUSTED_IP_HEADER | (unset) | Trusted reverse-proxy header for the real client IP (e.g. Fly-Client-IP, CF-Connecting-IP). Unset trusts the socket peer only. |
FEATHERREADER_MAX_SUBS_PER_DID | 500 | Per-DID subscription cap. |
FEATHERREADER_MAX_FEEDS | 10000 | Global distinct-feed ceiling. |
FEATHERREADER_MAX_ENTRIES_PER_FEED | 2000 | Per-feed retained-entry cap (newest N). |
FEATHERREADER_DB_SIZE_WATERMARK_BYTES | 2 GiB | Above this the poller stops fetching new content (0 disables). |
FEATHERREADER_RESOLVER_HOST | https://bsky.social | atproto handle-resolver base (com.atproto.identity.resolveHandle) the pre-handshake beta gate uses to honor an existing seat on a cookie-less login. |
FEATHERREADER_BOT_SECRET | (unset = /bot/claims disabled) | Shared bearer secret (X-Bot-Secret) gating the headless follow→invite bot’s mint endpoint POST /bot/claims. Unset ⇒ endpoint returns 503. MUST be set (strong) on a production-like instance if the bot is used. |
FEATHERREADER_CLAIM_TTL_SECS | 1209600 (14 days) | TTL for a bot-minted claim invite code — long, since the claim link is delivered asynchronously (a public skeet). |
FEATHERREADER_RELAY_HOSTS | relay1.us-west.bsky.network,relay1.us-east.bsky.network | Relays queried for the network adoption count. Bare hosts or full URLs. Setting it to the empty string names no relays and so disables the probe (unset ⇒ the defaults above; the two are deliberately distinguished). |
FEATHERREADER_ADOPTION_INTERVAL_SECS | 86400 (24h) | Adoption-probe cadence (±10% jitter). 0 disables the probe. |
FEATHERREADER_SHOW_ADOPTION | false | Render the one-line adoption fact on /about. |
The cutover switch and the Rust-native OAuth client it selects:
| Variable | Default | Meaning |
|---|---|---|
FEATHERREADER_REPO_BACKEND | sidecar | Which implementation serves com.atproto.repo.*: sidecar or rust. An unrecognised value FAILS startup rather than defaulting, since a silent fallback would make every side-by-side measurement a comparison of the sidecar with itself. The container entrypoint reads the same variable to install the matching Caddy OAuth routing — the two cannot share /oauth/callback, so they must agree. |
FEATHERREADER_STANDARD_SITE | false | Whether an at://…/site.standard.publication/… subscription may be stored — pasted into the subscribe form (a handle is resolved to its DID), imported via OPML, or written by another client. A stored publication is polled whatever this says: the flag gates storage, not reading (see feed::FeedKind). |
FEATHERREADER_OAUTH_KEY_PATH | oauth-signing-key.json | The client’s ES256 signing key, encrypted at rest in the SAME format the sidecar writes so one file serves both and a rollback finds what it expects. |
FEATHERREADER_OAUTH_ENCRYPTION_KEY | (unset = plaintext) | At-rest encryption for the signing key and stored sessions. Generate it, do not choose it — openssl rand -hex 32. The value is stretched with a single SHA-256 (pinned for byte-compatibility with the sidecar’s format), so its entropy is the ceiling, and the adversary this protects against is someone holding a volume snapshot with all the time in the world. |
FEATHERREADER_PLC_DIRECTORY | https://plc.directory | Directory used to resolve did:plc documents. |
FEATHERREADER_OAUTH_SCOPE | atproto transition:generic | Scope requested at login. Part of the dev client_id, so changing it changes the client’s identity in dev. |
The atproto OAuth sidecar (@atproto/oauth-client-node) is configured with a
second small block — the base URL the Rust server reaches it on and the shared
secret gating its internal API (see SidecarConfig):
| Variable | Default | Meaning |
|---|---|---|
SIDECAR_PUBLIC_URL | http://127.0.0.1:8081 | Public base URL of the OAuth sidecar (its browser-facing /login, plus the OAuth client_id/redirect_uri). |
SIDECAR_INTERNAL_URL | (= SIDECAR_PUBLIC_URL) | Loopback base URL the Rust server reaches the sidecar’s /internal/* API on. Defaults to the public URL for single-URL local dev. |
SIDECAR_INTERNAL_SECRET | (dev fallback) | Shared X-Internal-Secret for the sidecar’s /internal/* API. |
FEATHERREADER_COOKIE_SECRET | (dev fallback) | HMAC key used to sign the session cookie. |
FEATHERREADER_DEV_DID | (unset) | When set, a request with no session cookie acts as this DID (local runs without the sidecar). |
FEATHERREADER_BIND also accepts the design’s FEATHERREADER_ADDR spelling
as a fallback for compatibility.
Structs§
- Config
- Fully-resolved server configuration, materialized once at startup.
- Oauth
Config - Wiring for the Rust-native OAuth client.
- Sidecar
Config - Configuration for the atproto OAuth sidecar (
@atproto/oauth-client-node).