A self-hosted, single-file-database bookmark manager.
715
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.
#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)./ to search, n for a new bookmark).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.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.
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.
| Variable | Default | Description |
|---|---|---|
PORT | 80 (container) / 3000 (local dev) | Port the server listens on |
DATA_DIR | /app/data | Directory for the SQLite DB (mount this as a volume) |
SINGLE_USER_MODE | false | true disables login; every request acts as the admin user |
ADMIN_EMAIL | [email protected] | Created automatically on first boot if it doesn't exist |
ADMIN_PASSWORD | change-me | Password for the auto-created admin user |
SESSION_SECRET | (insecure default) | Set to a long random string in production |
COOKIE_SECURE | false | Set true once served over HTTPS |
REFETCH_INTERVAL_HOURS | 0 | Hours between automatic background metadata re-fetch; 0 disables it |
CORS_ORIGIN | true | Only relevant if frontend and API are on different origins |
See .env.example for the same list with inline comments.
# 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.
Manually adding a bookmark:
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.
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.
Content type
Image
Digest
sha256:a1c1c0b3f…
Size
63.5 MB
Last updated
about 2 months ago
docker pull oste/bookmarks