gather
Gather
A private, single-user, link-bookmarking web app. Closer to Linkding than to Pocket — small, fast, honest. No in-app reader, no page preview, no body fetch. Gather remembers you wanted to read a link, what you called it, and what tags you filed it under. The page stays on the page.
Self-hosted, SQLite-backed, GitHub OAuth login, HMAC-signed cookie sessions. One configured owner; strangers get 403.
What Gather is not
- Not a reader. No readability rendering, no article-body fetch.
- Not a previewer. No thumbnails, no Open Graph image rendering.
- Not multi-tenant. Single owner, gated by
OWNER_GITHUB_ID. The code is CSV-ready so multi-owner is an env change, not a code change. - Not a native client. The web UI is responsive; offline via PWA is a post-MVP option, not a current feature.
If a future feature needs server-side body fetch, that's a different product. See docs/01-overview.md for the locked decision record.
Stack
- Crystal 1.21, Kemal 1.12, SQLite via
crystal-db+crystal-sqlite3 micratefor migrations (plain SQL files)- ECR via Kilt, server-rendered — no SPA, no JS framework
- GitHub OAuth (
read:useronly), stateless HMAC-SHA256 cookie sessions myhtmlfor metadata fetch only (used byfetch_metadataservice)- No ORM, no DI container, no JWT shard — module singletons per
docs/08-dependencies.md
Quick start (dev)
Requires: crystal (via mise — see mise.toml), just, sqlite3, openssl.
just bootstrap # shards install, cp .env.example .env, mkdir db/migrations bin
$EDITOR .env # fill GATHER_SESSION_SECRET, GitHub OAuth creds, OWNER_GITHUB_ID
just migrate # apply migrations to db/gather.db
just dev # run on http://localhost:3000
just auto-loads .env for every recipe. The app itself never reads .env — it reads ENV directly. In production the supervisor (systemd / fly / docker) injects env.
Find your GitHub numeric id
curl -H "Authorization: token <PAT>" https://api.github.com/user | jq .id
OWNER_GITHUB_ID uses the immutable id, not login (login can be renamed; id cannot).
Generate a session secret
just gen-secret # openssl rand -hex 32 — paste into GATHER_SESSION_SECRET
Must be >= 32 bytes in production; Gather.settings.check_required! refuses to boot otherwise.
Deploy
Build a static binary:
just build # shards build gather --release — output at bin/gather
Run with env vars from your supervisor (systemd unit, fly.toml, or docker --env-file). Apply migrations at deploy time or boot with MIGRATE=1 (dev convenience; not the production default).
SQLite file lives on a persistent volume; WAL tolerates crash-restart. Don't ship the SQLite file with the binary. HTTPS is via a reverse proxy (Caddy / Traefik) — Gather serves plain HTTP on localhost; the OAuth callback URL and Secure cookie flag assume HTTPS at the edge. Never expose Gather directly to the internet.
See docs/02-architecture.md for the deployment notes and the SQLite pragma rationale (journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON, busy_timeout=5000).
Configuration
Every env key is documented in .env.example. The typed readers live in src/settings.cr. In production, missing required keys (GATHER_SESSION_SECRET, GATHER_GITHUB_CLIENT_ID, GATHER_GITHUB_CLIENT_SECRET, GATHER_OWNER_GITHUB_ID) cause Gather.settings.check_required! to Log.fatal and exit 1 — the app refuses to start in a broken state.
Project layout
gather/
├ shard.yml deps and build target
├ justfile dev recipes (spec, build, migrate, format, …)
├ .env.example env keys with placeholder values
├ db/
│ ├── gather.db gitignored runtime file
│ └ migrations/ micrate SQL files
├ src/
│ ├── gather.cr module root, VERSION, requires
│ ├── settings.cr typed ENV singleton, configure DSL
│ ├── db/ database.cr + repo/
│ ├── models/ DB::Serializable structs + converters
│ ├── services/ auth/, bookmarks/ (stateless orchestrators)
│ ├── middleware/ require_owner
│ ├── routes/ Kemal handlers
│ ├── helpers/ ECR mixins
│ ├── views/ ECR templates
│ └ assets/ app.css
└ spec/ in-memory SQLite, real repos, stub HTTP server
Tests
just spec # full suite, ~10ms, in-memory SQLite
just spec-one spec/db/bookmarks_repo_spec.cr
No mocks at the DB seam — same repos, same SQL as production. Per-spec in-memory SQLite with micrate up at suite boot, truncate between tests. See docs/06-testing.md.
Documentation
docs/ holds the locked first-iteration MVP design record — start with docs/README.md. Each pivot has rationale so future changes have a baseline to compare against. docs/PROGRESS.md tracks the 12-step build sequence.
Contributing
- Fork it (https://github.com/TunkShif/gather/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Run
just precommit(format-check, typecheck, spec) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
MIT — see LICENSE.
Contributors
- Tristan Yang — creator and maintainer
gather
- 0
- 0
- 0
- 0
- 4
- about 4 hours ago
- July 25, 2026
MIT License
Sat, 25 Jul 2026 09:34:49 GMT