Markcat Docs Open app →

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:

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.