Self-Hosting Guide
The source repository is not public yet; the source-install instructions below apply once it is available.
User Types
| Type | Description | Database | LLM |
|---|---|---|---|
| A | Everything local | SQLite | Ollama (local) or cloud API |
| B | Remote service, local Ollama | PostgreSQL | Ollama |
| C | Remote service, cloud LLM | PostgreSQL | OpenRouter / OpenAI / Anthropic |
| D | Fully hosted (markcat.app) | PostgreSQL | OpenRouter (managed) |
This guide covers types A, B, and C.
User A — Local (Default)
No configuration needed. SQLite database + SQLite blob storage.
pip install -r service/requirements.in
python -m service.main
# → http://127.0.0.1:8080
Moving blobs out of SQLite
If your database grows large, move blobs (favicons, OG images, screenshots) to the filesystem:
export BLOB_BACKEND=local
python -m service.scripts.migrate_blobs_to_storage --execute
python -m service.main
User B/C — Self-Hosted with PostgreSQL
With AUTH_MODE=jwt, serve the web app and API over HTTPS and configure their
actual origins. Refresh cookies are Secure; a plain-HTTP LAN origin can log in
but cannot retain the cookie needed to refresh an expired session. Use a TLS
reverse proxy or a private HTTPS origin such as Tailscale Serve. Local single-user
AUTH_MODE=none on loopback keeps its ordinary HTTP setup.
# 1. Install the PostgreSQL dependency set and configure the environment
pip install -r requirements-cloud.in
export DB_BACKEND=postgres
export DATABASE_URL=postgres://user:pass@server:5432/markcat
export BLOB_BACKEND=local
export BLOB_DIR=/data/markcat/blobs
export BIND_HOST=0.0.0.0 # default 127.0.0.1; with AUTH_MODE unset anyone who reaches the port can use it — set AUTH_MODE=jwt or keep it on a private network
# 2. Start service (creates schema automatically)
python -m service.main
# 3. (Optional) Migrate from an existing SQLite install. Move blobs first so the exported
# rows carry their file paths (BLOB_BACKEND and BLOB_DIR set as in step 1).
python -m service.scripts.migrate_blobs_to_storage --execute
python -m service.scripts.export_to_postgres --execute
Docker Deployment
For a production setup with a reverse proxy and auto-TLS, start from the
repository’s markcat-docker/ stack, a headless API deployment: FastAPI,
PostgreSQL and Caddy (443) in one Compose file, with its own Caddyfile. Its
README.md covers configuration.
cd markcat-docker
cp .env.example .env # set POSTGRES_PASSWORD (same password in DATABASE_URL), JWT_SECRET, SETTINGS_ENCRYPTION_KEY and APP_ORIGINS
docker compose up -d
To use the web app or PWA, build it with your API
origin — npm install, then
VITE_API_URL=https://api.<your-domain> npm run build --workspace=markcat-app —
and serve markcat-app/dist/ from your web domain. The optional compose’s Caddy
mounts that build; replace its example domains with your own before starting it.
The deploy/ directory is markcat.app’s own deployment: it runs behind a front-door
Caddy shared with other sites on the same box and expects that box’s edge network,
so it does not start on its own.
Browser isolation needs NET_ADMIN
In cloud mode (AUTH_MODE=jwt) the API container confines the page-rendering browser with
kernel network rules: a bookmarked page cannot open a connection to any private, loopback or
link-local address — your database, your LAN, the Docker network, the API’s own port inside
the container — or to any IPv6 address. Public addresses stay reachable, and that includes
your server’s own public IP: a port published there is reachable from a rendered page even
if a firewall in front of the server hides it from the internet. The bundled
markcat-docker compose therefore publishes its API port on 127.0.0.1 only.
Installing those rules needs the NET_ADMIN capability, which the bundled compose files
already grant (cap_add: [NET_ADMIN]). Without it the container stops at start-up with a
message saying so. Local mode needs no capability.
Outside the container there are no such rules, so a cloud-mode install run directly with
python -m service.main does not start the browser tiers. MARKCAT_ALLOW_PRIVATE_FETCH=1
turns the protection off entirely — use it only for a single trusted operator who wants to
bookmark LAN addresses.
Serve over HTTPS for PWA install & Android share
The PWA only installs in a secure context — HTTPS on any hostname, or localhost/127.0.0.1. Installing the PWA is also what registers Markcat in Android’s Share sheet (share-as-bookmark). Consequences for self-hosters:
- A plain-HTTP LAN address (e.g.
http://192.168.1.50:8080) is not a secure context — Chrome won’t offer install, and the share target never appears. - Behind a TLS reverse proxy (Caddy/nginx + Let’s Encrypt), or via a Tailscale/Cloudflare-tunnel HTTPS hostname, install and share target work exactly as on the hosted app.
This adds no requirement beyond what PWA install already needs — if “Add to Home Screen” works, the Android share target works.
Environment Variables
Required (Cloud Mode)
| Variable | Description |
|---|---|
DB_BACKEND |
postgres |
DATABASE_URL |
PostgreSQL connection string |
BLOB_BACKEND |
local (recommended) |
BLOB_DIR |
Path to blob storage directory |
Authentication (Multi-Tenant)
| Variable | Description |
|---|---|
AUTH_MODE |
jwt for multi-tenant mode; the default none is a single user without login and is the only mode that serves the interactive API reference (/docs, /redoc, /openapi.json on the service). Any other value refuses to start. |
JWT_SECRET |
Secret key for JWT signing, at least 32 characters — generate with openssl rand -hex 32. The service refuses to start on a shorter value or on the template placeholder. |
SETTINGS_ENCRYPTION_KEY |
Required when AUTH_MODE=jwt. Encrypts BYOK API keys at rest (AES-GCM). Generate with python -c "import os, base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())". |
APP_ORIGINS |
Required when AUTH_MODE=jwt unless CORS_ORIGINS is set: your web app origin(s), comma-separated (e.g. https://bookmarks.example.com). The service refuses to start without one. The first entry is the base of the links in password-reset, welcome and quota emails. |
CORS_ORIGINS |
Allowed origins (comma-separated) |
Billing (Optional)
| Variable | Description |
|---|---|
STRIPE_SECRET_KEY |
Stripe API key |
STRIPE_WEBHOOK_SECRET |
Stripe webhook signing secret |
STRIPE_PRICE_BASIC_MONTHLY / _ANNUAL |
Stripe price IDs for Basic plans |
STRIPE_PRICE_PRO_MONTHLY / _ANNUAL |
Stripe price IDs for Pro plans |
STRIPE_PRICE_TOPUP_500 / _2K / _10K |
Stripe price IDs for top-up packs |
The repository’s docs/reference/deployment.md holds the complete environment variable reference.
Database Backends
| Backend | File | Concurrency |
|---|---|---|
| SQLite | Single file, WAL mode | One process only |
| PostgreSQL | Connection pool (2-10) | Multiple instances OK |
Utility Scripts
Run with python -m service.scripts.<name>:
| Script | Purpose |
|---|---|
export_to_postgres |
Copy SQLite data to PostgreSQL |
migrate_blobs_to_storage |
Move blobs from SQLite to filesystem |
fix_duplicates |
Deduplicate bookmarks by normalized URL |
fix_favicons |
Re-fetch missing favicons |
find_dead_sites |
Detect dead bookmarks |
compact_categories |
LLM-powered category merging |
The repository’s docs/reference/scripts.md lists every script.