Sign inSign up

oste/bookmarks

By oste

Updated about 2 months ago

A self-hosted, single-file-database bookmark manager.

Image
0

715

oste/bookmarks repository overview

Bookmarks

A self-hosted, single-file-database bookmark manager. Fast with tens of thousands of bookmarks, ships as one Docker image, stores everything in a single SQLite file, and needs nothing else running alongside it.

Features

  • Capture — paste a URL, or use the bookmarklet for one-click saving from any browser (Settings → Integrations generates it for you, no manual editing). Type #tag1 #tag2 directly in the add/edit modal to create new tags on the fly. Title/description/preview image are fetched automatically in the background; duplicates are detected by normalizing URLs (tracking params, trailing slashes, www./protocol differences all get stripped before comparing).
  • Nested tags — arbitrary-depth tag hierarchy, custom colors, pin favorites to the sidebar. Selecting a parent tag includes all its children. An "Untagged" view surfaces anything you haven't sorted yet.
  • Instant search — SQLite FTS5 full-text search across title, description, URL, and notes as you type.
  • Four layouts — card (with preview images), list (compact), table (dense, sortable), and headlines (title-only, ultra-dense for large libraries). Sort by date added, date modified, title, or drag-and-drop manual order.
  • Bulk actions — multi-select to tag, delete, archive, favorite, or refetch metadata in one go.
  • Import / export — import Chrome/Firefox/Safari/Edge bookmark HTML exports (folders become nested tags), Pocket CSV, and Raindrop JSON. Export everything to portable JSON or standard Netscape bookmark HTML.
  • PWA — installable, works offline for the app shell, light/dark mode synced to system preference (including native form controls), collapsible sidebar, keyboard shortcuts (/ to search, n for a new bookmark).
  • Single-user or multi-user — set SINGLE_USER_MODE=true to skip login entirely for a homelab box behind Tailscale/Authelia, or run normal email/password auth with sessions stored in the same SQLite file, complete with a logout button in the sidebar account menu.

Quick start

git clone <this-repo> bookmarks && cd bookmarks
cp .env.example .env   # edit ADMIN_EMAIL / ADMIN_PASSWORD / SESSION_SECRET
docker compose up -d

The app is now at http://localhost:8080. All data lives under ./data (one SQLite file, bookmarks.sqlite3, plus its WAL files) — back that directory up and you have everything.

To use single-admin no-login mode instead, set SINGLE_USER_MODE=true in .env before starting.

Architecture

bookmark-app/
├── server/              Node.js + Express + better-sqlite3 backend
│   ├── src/
│   │   ├── db/          schema.sql, connection/migration
│   │   ├── lib/         URL normalization, metadata fetch, auth
│   │   └── routes/      auth, tags, bookmarks, import/export
│   └── public/          built frontend gets copied here at build time
├── web/                 React + Vite + Tailwind PWA frontend
├── Dockerfile           multi-stage build → single runtime image
└── docker-compose.yml

The backend serves the built frontend directly (no separate web server, no required reverse proxy), so the whole app is one process and one container. Sessions are stored in the same SQLite database via better-sqlite3-session-store — no Redis required, matching the "no mandatory external dependencies" goal.

Why SQLite + FTS5 instead of a job queue for metadata fetch: bookmark saves respond immediately and the title/description/image fetch happens fire-and-forget in the background using plain setTimeout/async work — no Redis or job queue needed for this scale. If you later want retries or fan-out across many workers, that's the natural place to introduce one, but it isn't needed to hit the "well under 100MB RAM idle" target.

Environment variables

VariableDefaultDescription
PORT80 (container) / 3000 (local dev)Port the server listens on
DATA_DIR/app/dataDirectory for the SQLite DB (mount this as a volume)
SINGLE_USER_MODEfalsetrue disables login; every request acts as the admin user
ADMIN_EMAIL[email protected]Created automatically on first boot if it doesn't exist
ADMIN_PASSWORDchange-mePassword for the auto-created admin user
SESSION_SECRET(insecure default)Set to a long random string in production
COOKIE_SECUREfalseSet true once served over HTTPS
REFETCH_INTERVAL_HOURS0Hours between automatic background metadata re-fetch; 0 disables it
CORS_ORIGINtrueOnly relevant if frontend and API are on different origins

See .env.example for the same list with inline comments.

Local development (without Docker)

# Terminal 1 — backend
cd server
npm install
SINGLE_USER_MODE=true PORT=3000 npm run dev

# Terminal 2 — frontend (proxies /api to :3000, see web/vite.config.js)
cd web
npm install
npm run dev

Frontend dev server runs at http://localhost:5173.

Screenshots

image image image

Manually adding a bookmark:

image image

The bookmarklet

No manual editing needed — open Settings → Integrations in the app itself. It generates the bookmarklet with your instance's URL already baked in; drag the "Add Bookmark" button straight to your bookmarks bar, or use "Copy code" on mobile. See bookmarklet.js for the readable, unminified source of what it actually does.

Clicking it always opens a new tab back at your instance with the current page's URL and title prefilled into the "Add bookmark" modal, so you can add notes/tags and confirm before it saves — this is more reliable than a silent background save, which browsers can block cross-origin depending on cookie settings.

API reference

All endpoints are under /api and require auth (session cookie, or none if SINGLE_USER_MODE=true) except /api/auth/* and /api/health.

POST   /api/auth/login          { email, password }
POST   /api/auth/logout
GET    /api/auth/session

GET    /api/bookmarks           ?q=&tag=&favorite=&archived=&untagged=&sort=&page=
POST   /api/bookmarks           { url, notes?, tag_ids? }
PATCH  /api/bookmarks/:id       { title?, description?, notes?, is_favorite?, is_archived?, tag_ids?, manual_order? }
DELETE /api/bookmarks/:id
POST   /api/bookmarks/:id/refetch
POST   /api/bookmarks/bulk      { ids: [...], action: tag|untag|delete|archive|unarchive|favorite|unfavorite|refetch, tag_id? }
POST   /api/bookmarks/reorder   { ids: [...] }  — sets manual_order to match array position (drag-and-drop)

GET    /api/tags
POST   /api/tags                { name, color?, parent_tag_id?, is_pinned? }
POST   /api/tags/find-or-create { name }  — returns existing tag or creates one with an auto-assigned color
PATCH  /api/tags/:id
DELETE /api/tags/:id

POST   /api/import              multipart/form-data, field "file", optional field "format" = netscape|pocket|raindrop
GET    /api/export               ?format=json|html

Sort values for GET /api/bookmarks: date_desc (default), date_asc, updated_desc, title_asc, title_desc, manual.

Tag summary

Content type

Image

Digest

sha256:a1c1c0b3f

Size

63.5 MB

Last updated

about 2 months ago

docker pull oste/bookmarks