Self-hosted RSS/Atom reader. A single Go binary serving an embedded Svelte SPA, a JSON API + Fever shim, and a background poller that ingests feeds into SQLite (FTS5). Everything runs in containers.
AI is fully optional. Ember can summarize articles with a small local LLM via Ollama, but it's an opt-out feature, not a dependency. Set
EMBER_DISABLE_SUMMARIES=1(or run the stack without theollamasidecar) and the reader works exactly the same — no summary card, no model download, no inference, no LLM-related code paths. Even when enabled, everything runs on your own box; no article content leaves the host. Pick the deployment that matches your stance.
Three options, in order of effort. Each has a walkthrough in docs/getting-started.md:
ghcr.io/brandonhon/ember:vX.Y.Z (also :X.Y, :X, :latest). Multi-arch linux/amd64 + linux/arm64. Either docker run a single container to kick the tires, or swap the build: block in deploy/docker-compose.yml for image: ghcr.io/brandonhon/ember:vX.Y.Z to pull instead of building.linux-{amd64,arm64}, darwin-{amd64,arm64}) + SHA256SUMS. Includes a sample systemd unit.VERSION=v0.6.0
curl -L -o ember.tar.gz \
"https://github.com/brandonhon/ember/releases/download/${VERSION}/ember-${VERSION}-linux-amd64.tar.gz"
tar -xzf ember.tar.gz && ./ember --version
cd deploy
cp .env.example .env
# Edit .env — set EMBER_SESSION_KEY (32+ random bytes) and EMBER_ADMIN_PASSWORD
docker compose up -d
Open https://localhost (Caddy serves the SPA + reverse-proxies the API). Log in with the admin credentials you set in .env.
On first boot:
ollama-pull container fetches the configured model (default qwen2.5:0.5b).ember container creates the admin user from EMBER_ADMIN_USER / EMBER_ADMIN_PASSWORD.EMBER_POLL_TICK).You'll land on an onboarding panel that points to starter packs or OPML import. Pick a pack and you're off.
EMBER_DISABLE_SUMMARIES / EMBER_DISABLE_IMAGES.ember probe).CLEANED body with newsletter
signups, podcast/app promos, and social follow asks removed. Falls back to
the original when the model can't produce a full body.mark_read, star, or hide actions.?tag=… filter on the list endpoint./data/exports/.<link rel=alternate> and probes common feed paths (/feed, /rss, /atom.xml, /feed.xml, /index.xml).EMBER_PUBLIC_URL.EMBER_SMTP_* env vars.(N) Ember so narrow tab strips show the count too.color-mix().ember probe subcommand reports RAM/CPU/GPU and recommends a model.app_settings and run via an hourly maintenance goroutine.window.confirm)./fever./api).| Var | Default | Purpose |
|---|---|---|
EMBER_SESSION_KEY |
(required) | securecookie key (32+ bytes) |
EMBER_ADMIN_PASSWORD |
(required first run) | first-run admin password |
| Var | Default | Purpose |
|---|---|---|
EMBER_ADDR |
:8080 |
listen address |
EMBER_DB_PATH |
/data/ember.db |
SQLite file |
EMBER_ADMIN_USER |
admin |
first-run admin username |
EMBER_OLLAMA_URL |
http://ollama:11434 |
summarizer endpoint |
EMBER_OLLAMA_MODEL |
qwen2.5:0.5b |
initial model (admin can swap later) |
EMBER_DISABLE_SUMMARIES |
0 |
skip LLM summarization entirely |
EMBER_DISABLE_IMAGES |
0 |
drop article hero images at ingest |
EMBER_FRESH_WINDOW |
6h |
"Fresh" cutoff |
EMBER_POLL_CONCURRENCY |
8 |
poller workers |
EMBER_POLL_TICK |
60s |
scheduler tick |
EMBER_SESSION_TTL |
24h |
session cookie lifetime (5m–90d); Settings → Sessions overrides at runtime |
EMBER_LOG_LEVEL |
info |
slog level |
EMBER_TEST_MODE |
0 |
enables fake fetcher/summarizer for e2e |
EMBER_PUBLIC_URL |
(unset) | canonical scheme://host users hit; required to enable passkey sign-in |
EMBER_SMTP_HOST |
(unset) | SMTP host; required to enable daily-digest emails |
EMBER_SMTP_PORT |
587 |
SMTP port |
EMBER_SMTP_USER |
(unset) | SMTP auth user (optional) |
EMBER_SMTP_PASSWORD |
(unset) | SMTP auth password |
EMBER_SMTP_FROM |
(unset) | digest From: address |
EMBER_SMTP_STARTTLS |
1 |
enable STARTTLS on submission ports |
Stored in the app_settings KV; persist across restarts:
EMBER_SESSION_TTL)make web-install # one-time
make test # go tests
make web-test # vitest
make embed # build SPA + copy to internal/web/dist
make build # produce ./bin/ember
EMBER_TEST_MODE=1 ./bin/ember # listens on :8080 with the noop summarizer
Hot reload for the SPA:
cd web && npm run dev # vite dev server, proxies /api → :8080
EMBER_TEST_MODE=1 ./bin/ember # in another terminal
# visit http://localhost:5173
Reeder, FeedMe, and other Fever-compatible apps can connect via /fever. The api_key is md5("<username>:<user_id>") — see /api/me for your user_id. (We can't use the canonical md5("user:pass") because passwords are stored only as argon2id hashes.)
make embed build # produce ./bin/ember with the SPA embedded
cd web && npx playwright install chromium
npx playwright test # spawns the binary in test mode against a temp DB
In test mode (EMBER_TEST_MODE=1) the binary seeds a deterministic admin (admin / admintest) plus 12 fixture articles and a single feed, so every spec has known data to assert against.
SQLite with WAL mode, 64 MiB cache, 256 MiB mmap, busy_timeout=5s, synchronous=NORMAL. Single connection — SQLite serializes writes, and the workload is small enough that the connection pool isn't a bottleneck. PRAGMA optimize runs after every startup migrate. Backups via VACUUM INTO are safe to run live.
Migration files live under internal/db/migrations/ and are embedded into the binary.
See docs/architecture.md for the request lifecycle, poller state machine, and summarizer pipeline.