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.
- 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.
- 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
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:3000just 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.
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).
just gen-secret # openssl rand -hex 32 — paste into GATHER_SESSION_SECRETMust be >= 32 bytes in production; Gather.settings.check_required! refuses to
boot otherwise.
Build a static binary:
just build # shards build gather --release — output at bin/gatherRun 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).
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.
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
just spec # full suite, ~10ms, in-memory SQLite
just spec-one spec/db/bookmarks_repo_spec.crNo 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.
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.
- 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
MIT — see LICENSE.
- Tristan Yang — creator and maintainer