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 -dOpen 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 -d6. 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.