self-hosting

run your own copy on your own box. no clone needed — grab two files, fill in a few secrets, and you're reading.

1. prerequisites

Docker and Docker Compose. That's it.

2. copy compose.yml

Save this as compose.yml in a new directory:

name: readmepls

services:
  pocketbase:
    image: ghcr.io/g1sbi/readmepls-pocketbase:${IMAGE_TAG:-latest}
    restart: unless-stopped
    env_file: .env
    ports:
      - "${PB_PORT:-8090}:8090"
    volumes:
      - pb_data:/pb_data
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8090/api/health"]
      interval: 10s
      timeout: 3s
      start_period: 20s
      retries: 5

  web:
    image: ghcr.io/g1sbi/readmepls-web:${IMAGE_TAG:-latest}
    restart: unless-stopped
    env_file: .env
    environment:
      PB_URL: http://pocketbase:8090
      # Browser-facing PB origin. From .env by default; overridable at runtime
      # (the smoke test injects a sentinel to prove it reaches the browser).
      PUBLIC_PB_URL: ${PUBLIC_PB_URL:-http://localhost:8090}
    ports:
      - "${WEB_PORT:-3000}:3000"
    depends_on:
      pocketbase:
        condition: service_healthy

  worker:
    image: ghcr.io/g1sbi/readmepls-worker:${IMAGE_TAG:-latest}
    restart: unless-stopped
    env_file: .env
    environment:
      PB_URL: http://pocketbase:8090
      # Empty by default (real provider). The smoke test exports AI_PROVIDER=mock
      # so the worker completes jobs offline; overrides the .env value.
      AI_PROVIDER: ${AI_PROVIDER:-}
      # Bind the internal /search server to all interfaces so `web` can reach it
      # container-to-container (the worker defaults to loopback-only otherwise).
      WORKER_HTTP_HOST: 0.0.0.0
      # Points yt-dlp at the mounted cookies file ONLY when YOUTUBE_COOKIES_HOST
      # is set; otherwise expands to empty and the worker sends no --cookies.
      YOUTUBE_COOKIES_FILE: ${YOUTUBE_COOKIES_HOST:+/run/secrets/yt-cookies.txt}
      # bgutil PO-token provider (sidecar below). yt-dlp fetches YouTube PO
      # tokens from it to get past the datacenter-IP bot-block — no account
      # needed. Set YOUTUBE_POT_PROVIDER_URL="" in .env to disable.
      YOUTUBE_POT_PROVIDER_URL: ${YOUTUBE_POT_PROVIDER_URL-http://bgutil:4416}
    expose:
      # Internal semantic /search endpoint — reachable only on the compose network,
      # never published to the host. `web` calls it with the shared secret.
      - "8091"
    volumes:
      # Persist the downloaded embedding model across restarts (first capture
      # fetches it once into TRANSFORMERS_CACHE=/data/models).
      - worker_models:/data/models
      # Optional YouTube cookies (Netscape format) to defeat the datacenter-IP
      # bot-block. Falls back to /dev/null when YOUTUBE_COOKIES_HOST is unset —
      # harmless, since YOUTUBE_COOKIES_FILE is then empty and never read.
      - ${YOUTUBE_COOKIES_HOST:-/dev/null}:/run/secrets/yt-cookies.txt:ro
    depends_on:
      pocketbase:
        condition: service_healthy
      bgutil:
        condition: service_started

  # PO-token provider for yt-dlp's YouTube extractor. Generates BotGuard tokens
  # so the worker's captures survive YouTube's datacenter-IP bot-block without
  # an account or cookies. Internal-only — never published to the host.
  bgutil:
    image: brainicism/bgutil-ytdlp-pot-provider:latest
    restart: unless-stopped
    expose:
      - "4416"

volumes:
  pb_data:
  worker_models:

3. copy .env.example → .env

Save this as .env next to it, then fill in the PocketBase admin/worker passwords.

# ---- AI provider ----
ANTHROPIC_API_KEY=
AI_MODEL=claude-haiku-4-5
# Leave empty for the real Claude provider. Set to "mock" ONLY for the smoke
# test (scripts/smoke-test.sh) to process jobs offline with no API key/cost.
AI_PROVIDER=

# ---- Deployment mode ----
# false (default) = hosted SaaS: tier is per-user, self-serve via /profile.
# true = self-hosted: tier is NOT per-user — everyone on this instance is Pro
# if ANTHROPIC_API_KEY is set (or AI_PROVIDER=mock), else everyone is Standard.
SELF_HOSTED=false

# true = only the first account created can sign in; all further signups are
# rejected (both by the API and in the UI). Only takes effect when
# SELF_HOSTED=true. Leave false for a normal multi-user instance.
SINGLE_ACCOUNT=false

# ---- SaaS signup email verification (SELF_HOSTED=false only) ----
# Required for hosted SaaS so signups can receive their verification email.
# Ignored entirely when SELF_HOSTED=true (self-host has no email gate).
SMTP_HOST=
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
# true = enforce TLS on connect; false = allow STARTTLS upgrade.
SMTP_TLS=true
# Envelope/from address shown to recipients.
SMTP_FROM=no-reply@example.com
SMTP_FROM_NAME=readmepls

# ---- PocketBase superusers (provisioned on first boot) ----
# Human admin — log in at http://localhost:8090/_/
PB_ADMIN_EMAIL=admin@example.com
PB_ADMIN_PASSWORD=change-me-admin

# Dedicated worker superuser (separate, independently revocable)
PB_WORKER_EMAIL=worker@example.com
PB_WORKER_PASSWORD=change-me-worker

# ---- Service URLs ----
# Internal (container-to-container). Leave as-is for compose.
PB_URL=http://pocketbase:8090
# Browser-facing PocketBase URL (used by the web client).
PUBLIC_PB_URL=http://localhost:8090
# SvelteKit origin (CSRF/cookies) — must match the URL users hit.
ORIGIN=http://localhost:3000

# ---- Ports / runtime ----
PORT=3000
WEB_PORT=3000
PB_PORT=8090
WORKER_POLL_MS=2000
# How often the worker renews its PocketBase superuser auth token (default 1h;
# the token itself expires after 24h server-side, so keep this well under that).
WORKER_AUTH_REFRESH_MS=3600000

# ---- Container images ----
# Published image tag for web, worker, and pocketbase services.
# latest = production default. Staging deployment overrides to develop.
IMAGE_TAG=latest

# ---- YouTube extractor (worker) ----
# YouTube bot-blocks datacenter/VPS IPs ("Sign in to confirm you're not a bot").
# The compose stack ships a bgutil PO-token sidecar that gets past this WITHOUT
# an account — it's on by default (YOUTUBE_POT_PROVIDER_URL -> http://bgutil:4416).
# Set the var to empty below to disable the sidecar integration.
YOUTUBE_POT_PROVIDER_URL=http://bgutil:4416
#
# Cookies are the fallback if PO tokens alone still get 403'd on your IP. Export
# cookies from a browser logged into a (throwaway) Google account in Netscape
# format, save the file on the host, and set its absolute path here. Leave blank
# otherwise. Cookies expire and can be invalidated — refresh if extraction starts
# failing again. Use a burner account; YouTube may ban accounts used to scrape.
YOUTUBE_COOKIES_HOST=

# ---- Semantic search embedding (worker) ----
# Leave EMBED_PROVIDER unset to use the local ONNX model (multilingual-e5-small,
# int8, no key, no inference-time network). Set EMBED_PROVIDER=fake for
# offline/deterministic runs (tests, smoke).
EMBED_PROVIDER=
# Where transformers.js caches the downloaded model (persist this in Docker).
TRANSFORMERS_CACHE=/data/models
# One-shot: set to 1 to embed all pre-existing content on next worker boot.
BACKFILL_EMBEDDINGS=

# ---- Semantic search query path (worker /search ↔ web) ----
# Shared secret protecting the worker's internal /search endpoint. Both the worker
# and the web app read it from this .env, so a single value here wires both sides.
# Generate: openssl rand -hex 32. Leave empty to disable semantic /search (the
# library then falls back to keyword-only search).
WORKER_SEARCH_SECRET=
# Port the worker serves /search on (internal compose network only).
WORKER_HTTP_PORT=8091
# Interface the worker binds /search to. In Docker this MUST be 0.0.0.0 so `web`
# can reach it (compose overrides this per-service); loopback-only otherwise.
WORKER_HTTP_HOST=0.0.0.0
# How the web app reaches the worker's /search (internal service URL).
WORKER_URL=http://worker:8091

# --- Chrome extension ---
# Comma-separated allow-list of extension origins permitted to call /api/* with a
# bearer token (CORS). Format: chrome-extension://<id>. Empty disables the extension
# (no origin is allowed). Add the unpacked-dev id and the published id here.
EXTENSION_ORIGINS=

4. pull and run

docker compose pull
docker compose up -d

Open http://localhost:3000 (or whatever WEB_PORT you set).

5. updating

Same command as above — pulls the latest images and restarts:

docker compose pull
docker compose up -d

6. data

Everything lives in the pb_data Docker volume. Back that up, back up everything that matters.

7. AI features: on or off

Self-hosting has no tiers, no plans, no subscriptions — that's a hosted-SaaS thing. The reader is fully functional with nothing set. Add an ANTHROPIC_API_KEY to .env and AI features (auto-tagging and friends) switch on for everyone using your instance. One switch, not a choice between plans.

Questions or something looks off? Open an issue on GitHub.