A self-hosted home for your audiobooks — and, since they live on the same shelf anyway, your ebooks and comics too.
Most self-hosted media servers treat audiobooks as an afterthought: music with an unusually long runtime, no real chapter handling, progress that resets the moment you switch devices. Bookmark starts from the opposite assumption. Audiobooks come first here. Chapters work properly, your position follows you everywhere, and the player is built around how people actually listen — speed control, skip back when you zone out, a sleep timer for the last chapter of the night.
Once that part was solid, ebooks and comics were a small leap rather than a separate project. So if audiobooks are your main thing, Bookmark should be the obvious choice. And if you'd rather not run three different servers for the rest of your library, it's a genuinely good place to keep your ebooks and comics as well.
Point it at the folders you already have. It scans them, fills in covers and metadata, and never rewrites your original files.
Audiobooks are the heart of it. Stream straight from the browser with full M4B chapter support, variable playback speed, skip controls, and a sleep timer. Your progress syncs to the server every few seconds, so you can start on your laptop and pick up exactly where you left off on your phone.
Ebooks are scanned from your EPUB collection and exposed through an OPDS feed, so you can read them in whatever OPDS-compatible app you already use. (An in-browser reader is on the way — see the roadmap.)
Comics are organized the way you'd expect: series and issues, including TPBs, omnibuses, and one-shots, scanned folder-by-folder from CBZ, CBR, and PDF archives. Metadata comes from embedded ComicInfo.xml and Comic Vine. You can browse, organize, and download today; the in-browser reader is still to come.
Beyond the media itself:
- Metadata that fills itself in — covers and chapters pulled from your files, enriched by Hardcover and Audnexus for books and Comic Vine for comics.
- Multiple users — everyone gets their own progress, lists, and preferences, with per-user permissions for editing metadata, uploading, or issuing API keys. Tag-based filters can keep certain content out of certain accounts.
- SSO — optional OpenID Connect, so it slots into an existing Authentik / Keycloak / Authelia setup.
- Live updates — scans and imports report progress over WebSocket instead of making you refresh.
- A REST API — documented with Swagger, intended to keep the door open for native mobile apps later.
- Yours to make your own — light and dark themes, custom accent colors, and currently shipping in English and Swedish (more translations very welcome).
It's honest about where it is, too. A few things — Hardcover progress sync, the AudiobookShelf importer, and the full breadth of the API — are partially there and still being worked on. The roadmap below is the source of truth.
It's a TypeScript monorepo (Turborepo + pnpm) with two apps.
| Area | Built with |
|---|---|
| Web app | Next.js 16 (App Router), React 19, Tailwind CSS 4, TanStack Query |
| API | NestJS 11, PostgreSQL, Drizzle ORM |
| Auth | Better Auth — sessions, API keys, and optional OIDC |
| Real-time | Socket.IO |
| Media handling | FFmpeg (audio + chapters), Sharp (covers), pdf.js, node-unrar, EPUB |
| i18n | next-intl (English + Swedish) |
The frontend talks to the API over a Next.js proxy, which keeps the same setup working for a future mobile client. Covers and metadata are processed in background workers so a big first scan doesn't block the app.
The quickest path is Docker. Bookmark ships as a single image (web app + API together) plus a Postgres database — two containers, one command. You'll need Docker and Docker Compose; for local development you'll also want Node.js 20+, PostgreSQL 16+, pnpm 9+, and FFmpeg.
git clone https://github.com/RobinEdquist/bookmark.git
cd bookmark
# Pre-built image, exposes port 3001
docker compose up -d
# Or build from source
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --buildThat's it for a local try-out — an auth secret is generated on first start, and everything defaults to http://localhost:3001. For a real deployment (your own domain, media folders), copy the config template and fill in the essentials:
cp example.env .envPUBLIC_URL=https://bookmark.yourdomain.com
POSTGRES_PASSWORD=pick-something-strong
# Your media (read-only mounts — Bookmark never writes to them)
AUDIOBOOK_LIBRARY_PATH=/path/to/your/audiobooks
EBOOK_LIBRARY_PATH=/path/to/your/ebooks
COMIC_LIBRARY_PATH=/path/to/your/comicsVisit http://localhost:3001 (or your domain). The first account you create becomes the admin. From there, head to Settings → Libraries, point Bookmark at your folders, hit Scan, and you're set.
Everything is set through environment variables in your .env file. With Docker, a few internal values (the database URL, the internal service URLs) are derived for you, so the lists below are the variables you actually touch.
Core
| Variable | Required | Default | Description |
|---|---|---|---|
PUBLIC_URL |
For remote access | http://localhost:3001 |
Full URL where Bookmark is reachable; used for auth and CORS |
BETTER_AUTH_SECRET |
No | auto-generated | Secret for signing sessions; generated and persisted in the data volume on first start if unset |
POSTGRES_PASSWORD |
No | postgres |
Database password — change it before exposing anything |
POSTGRES_USER |
No | postgres |
Database user |
POSTGRES_DB |
No | bookmark |
Database name |
PORT |
No | 3001 |
Host port the web app is published on |
LOG_LEVEL |
No | info |
Backend log verbosity (debug, info, warn, error) |
Media and storage
| Variable | Required | Default | Description |
|---|---|---|---|
DATA_PATH |
No | ./data |
Host path for app data (database, covers) |
AUDIOBOOK_LIBRARY_PATH |
No | ./media/audiobooks |
Your audiobook folder (mounted read-only) |
EBOOK_LIBRARY_PATH |
No | ./media/ebooks |
Your ebook folder (mounted read-only) |
COMIC_LIBRARY_PATH |
No | ./media/comics |
Your comic folder (mounted read-only) |
Single sign-on (optional) — leave OIDC_ENABLED=false to use the built-in email/password login.
| Variable | Required | Default | Description |
|---|---|---|---|
OIDC_ENABLED |
No | false |
Turn OpenID Connect SSO on or off |
OIDC_ISSUER_URL |
If SSO enabled | — | Issuer URL from your identity provider |
OIDC_CLIENT_ID |
If SSO enabled | — | Client ID |
OIDC_CLIENT_SECRET |
If SSO enabled | — | Client secret |
AI-narrated audiobooks (optional) — Bookmark can narrate an ebook into a real audiobook using any OpenAI-compatible text-to-speech server (/v1/audio/speech). A ready-to-use, CPU-friendly engine ships behind a compose profile:
docker compose --profile tts up -dThen enter http://tts:8880 as the server URL under Settings → Integrations → Text-to-speech — no env vars needed. Prefer a different engine, voice, or language? Point the server URL at any other OpenAI-compatible TTS server, self-hosted or cloud, and it works as a drop-in replacement. Generated audiobooks are written to DATA_PATH/generated-audiobooks, which appears inside the audiobook library as a generated folder — your own media stays on a read-only mount.
Content requests (optional) — Bookmark can let users search an external catalog and request titles that aren't in the library yet, with an admin approval flow. The searching and downloading itself is handled by a content request module — a separate HTTP service you run alongside Bookmark. Point Bookmark at one, then enable requests under Settings. Want to build a module? See the developer guide and the OpenAPI spec it must implement.
| Variable | Required | Default | Description |
|---|---|---|---|
TRACKER_CLIENT_URL |
If requests enabled | — | Base URL of your content request module |
TRACKER_CLIENT_API_KEY |
If requests enabled | — | Shared secret the module expects as X-API-Key |
Image — which Bookmark image docker compose up runs. Ignored when building from source with docker-compose.build.yml.
| Variable | Required | Default | Description |
|---|---|---|---|
BOOKMARK_IMAGE |
No | ghcr.io/robinedquist/bookmark:latest |
Full image reference to deploy |
If you start the apps directly (see Development), the Docker-derived values aren't set for you — point the services at each other with these:
| Variable | Service | Description |
|---|---|---|
DATABASE_URL |
API | Postgres connection string |
BETTER_AUTH_URL |
API | The API's own base URL (e.g. http://localhost:3000) |
UI_URL |
API | Frontend origin, for CORS (e.g. http://localhost:3001) |
APP_DATA_PATH |
API | Where covers and cache are written (required in production) |
API_URL |
Web | Backend URL the web app proxies to (e.g. http://localhost:3000) |
NEXT_PUBLIC_API_URL |
Web | Backend URL exposed to the browser |
NODE_ENV follows the usual Node convention and is set for you in Docker; DEBUG only adds verbose output to the end-to-end tests.
- Audiobooks — M4B (with chapters), MP3, M4A/AAC, OGG/Opus
- Ebooks — EPUB
- Comics — CBZ, CBR, PDF
pnpm install
cp example.env .env # point it at a local Postgres
# apply the schema
cd apps/backend && pnpm db:migrate && cd ../..
pnpm dev # web on :3001, API on :3000Useful scripts:
pnpm build # build everything
pnpm lint # lint everything
pnpm check-types # type-check everything
pnpm test # unit tests
pnpm test:e2e # end-to-end testsAPI docs (Swagger) live at http://localhost:3000/api/docs once the backend is up. The repo is laid out as apps/web (Next.js), apps/backend (NestJS), and apps/website (the static marketing site, dev server on :3002), with shared code under packages/.
- Hardcover for book metadata and ratings
- Comic Vine for comic series and issue metadata (used under its non-commercial terms)
- Audnexus for extra audiobook metadata
- FFmpeg for the heavy lifting on media
MIT © Robin Edquist