Self-host DigMyGem
Run DigMyGem on your own computer or server with Docker Compose, and keep all your data on it.
You need Docker and Docker Compose. No account and no API keys are needed: the local dictionary covers common words, and free online translation services handle the rest.
Save these files
Make a folder at ~/digmygem, then save the Compose file below as docker-compose.yml and the settings as .env. You do not need to clone any repository.
docker-compose.yml
The app image is pulled from ghcr.io.
services:
# Optional Trafilatura sidecar for Discover (#1550). Started only with
# COMPOSE_PROFILES=trafilatura; the app does not wait for it and falls back to
# its own extractor when it is absent. Its port is not published and it takes
# no authentication.
article-extractor:
image: ghcr.io/jwlyn/digmygem-article-extractor:${DIGMYGEM_ARTICLE_EXTRACTOR_VERSION:-latest}
container_name: digmygem-article-extractor
restart: unless-stopped
profiles: ['trafilatura']
healthcheck:
test:
[
'CMD',
'python',
'-c',
"import urllib.request; urllib.request.urlopen('http://localhost:8091/health')",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
digmygem:
image: ghcr.io/jwlyn/digmygem:${LECTOR_VERSION:-latest}
container_name: digmygem
restart: unless-stopped
# PID 1 relays `docker stop` to the entrypoint.
init: true
volumes:
- ${DATA_PATH:-./data}:/app/data
# On-device dictionaries (lector#438). The image ships none of them, so this
# volume must outlive an image pull. Without it every `docker compose
# pull && up -d` throws the files away and downloads them again.
#
# A named volume, not a path under DATA_PATH: the dictionaries are large
# and re-downloadable, so they stay out of whatever backs up your data
# directory. Set DICT_DIR=/app/data/dict instead if you would rather keep
# one directory for everything.
- digmygem-dict:/app/dict
ports:
- '${WEB_PORT:-3400}:3000'
# The Hono API is published on a fixed host :3457; the browser is told
# where to reach it via API_URL below (which carries host + port).
# Loopback by default: the API can attach the operator's LLM key, so it
# must not answer the LAN/Tailnet. Rebind it in docker-compose.override.yml.
- '127.0.0.1:3457:3457'
environment:
- NODE_ENV=production
- DATA_DIR=/app/data
# Deployment mode — selfhost (default) or cloud. Cloud refuses to boot
# until accounts/auth ship (#218; seam tracked in #242).
- DIGMYGEM_MODE=${DIGMYGEM_MODE:-${LECTOR_MODE:-selfhost}}
- DIGMYGEM_TTS_HIGHLIGHT_LAB=${DIGMYGEM_TTS_HIGHLIGHT_LAB:-}
# Family account cap, counting the owner and accepted/pending members.
- DIGMYGEM_FAMILY_MEMBER_LIMIT=${DIGMYGEM_FAMILY_MEMBER_LIMIT:-}
# Public launch homepage routing (deploy/README.md, "Launch homepage").
# DIGMYGEM_LAUNCH_HOSTS replaces the default host list; DIGMYGEM_APP_ORIGIN
# is where non-root paths redirect. Leave both unset for the defaults.
- DIGMYGEM_LAUNCH_HOSTS=${DIGMYGEM_LAUNCH_HOSTS:-}
- DIGMYGEM_APP_ORIGIN=${DIGMYGEM_APP_ORIGIN:-}
# Public origin used in share cards, canonical links, sitemap.xml and
# robots.txt, e.g. https://gems.example.org. Unset → derived from the request host.
- DIGMYGEM_PUBLIC_ORIGIN=${DIGMYGEM_PUBLIC_ORIGIN:-}
- DIGMYGEM_CF_ANALYTICS_TOKEN=${DIGMYGEM_CF_ANALYTICS_TOKEN:-}
- DIGMYGEM_GOOGLE_SITE_VERIFICATION=${DIGMYGEM_GOOGLE_SITE_VERIFICATION:-}
- DIGMYGEM_BING_SITE_VERIFICATION=${DIGMYGEM_BING_SITE_VERIFICATION:-}
- DIGMYGEM_DMCA_AGENT_NAME=${DIGMYGEM_DMCA_AGENT_NAME:-}
- DIGMYGEM_DMCA_AGENT_ORG=${DIGMYGEM_DMCA_AGENT_ORG:-}
- DIGMYGEM_DMCA_AGENT_ADDRESS=${DIGMYGEM_DMCA_AGENT_ADDRESS:-}
- DIGMYGEM_DMCA_AGENT_PHONE=${DIGMYGEM_DMCA_AGENT_PHONE:-}
- DIGMYGEM_DMCA_AGENT_EMAIL=${DIGMYGEM_DMCA_AGENT_EMAIL:-}
# Browser-facing API origin — the web app calls Hono directly, so this
# must be reachable FROM THE BROWSER (the host/port you open the app on),
# not an internal Docker name. Served to the browser via /__env.js. Unset →
# falls back to http://localhost:3457 (only correct when browsing locally).
- API_URL=${API_URL:-}
# Cloud mode (DIGMYGEM_MODE=cloud) accounts/auth — see deploy/README.md's
# "Cloud mode" section. BETTER_AUTH_URL is the API's OWN origin (Better
# Auth serves /api/auth/* from here, and the browser resolves the same
# origin through apiBase()) — not the UI's. In a split UI/API topology
# (different hostnames) the UI's origin goes in LECTOR_TRUSTED_ORIGINS
# instead, or credentialed cross-origin requests are refused.
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-}
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-}
- DIGMYGEM_TRUSTED_ORIGINS=${DIGMYGEM_TRUSTED_ORIGINS-${LECTOR_TRUSTED_ORIGINS:-}}
- DIGMYGEM_STRICT_FETCH_SCOPES=${DIGMYGEM_STRICT_FETCH_SCOPES:-}
# `cloudflare` only when Cloudflare is the only way to reach this API: the
# sign-in rate limits then count per visitor from CF-Connecting-IP.
- DIGMYGEM_TRUSTED_PROXY=${DIGMYGEM_TRUSTED_PROXY-${LECTOR_TRUSTED_PROXY:-}}
# Sign in with GitHub (optional).
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
# Sign in with an OpenID Connect provider (optional). All three of
# OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET are required together.
- OIDC_ISSUER=${OIDC_ISSUER:-}
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-}
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-}
- OIDC_PROVIDER_NAME=${OIDC_PROVIDER_NAME:-}
- OIDC_SCOPES=${OIDC_SCOPES:-}
# Verification/reset mail (optional). Without RESEND_API_KEY, mail lands
# in the server log instead of being sent.
- RESEND_API_KEY=${RESEND_API_KEY:-}
- EMAIL_FROM=${EMAIL_FROM:-}
- DIGMYGEM_MAIL_POSTAL_ADDRESS=${DIGMYGEM_MAIL_POSTAL_ADDRESS:-}
# Turnstile bot protection on sign-up (optional).
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
- TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY:-}
# Optional analytics script URL. Empty by default, so nothing loads.
- PLAUSIBLE_SRC=${PLAUSIBLE_SRC:-}
- LLM_PROVIDER=${LLM_PROVIDER:-openai}
# Discover article body: ours | trafilatura. See .env.example.
- DIGMYGEM_ARTICLE_EXTRACTOR=${DIGMYGEM_ARTICLE_EXTRACTOR:-ours}
- DIGMYGEM_ARTICLE_EXTRACTOR_URL=${DIGMYGEM_ARTICLE_EXTRACTOR_URL:-http://article-extractor:8091}
# OpenAI-compatible backend. Empty means none.
- OPENAI_COMPAT_URL=${OPENAI_COMPAT_URL:-}
- OPENAI_COMPAT_MODEL=${OPENAI_COMPAT_MODEL:-}
# Per-task model pin for the bounded managed phrase translation path.
# Optional. They apply only when OPENAI_COMPAT_URL is OpenRouter; unset
# means google/gemini-2.5-flash-lite. Another model, or another provider
# URL, only warns at boot when DIGMYGEM_FREE_TIER=true.
- OPENAI_COMPAT_SIMPLE_PHRASE_MODEL=${OPENAI_COMPAT_SIMPLE_PHRASE_MODEL:-}
# Required by any OpenAI-compatible backend that authenticates — Gemini,
# DeepSeek, OpenRouter.
- OPENAI_COMPAT_API_KEY=${OPENAI_COMPAT_API_KEY:-}
# 1 when OPENAI_COMPAT_URL serves translation only, 0 when it can chat.
- OPENAI_COMPAT_TRANSLATION_ONLY=${OPENAI_COMPAT_TRANSLATION_ONLY:-}
# The managed fallback chain after your provider: Gemini, then Gemma.
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
# off stops the background lookup of meanings for saved words that have none.
- DIGMYGEM_MEANING_FILL=${DIGMYGEM_MEANING_FILL:-}
# YouTube import needs a residential exit on a cloud host (#946): route
# only its two calls through an HTTP proxy or an operator-run relay.
# Empty is off, and is what a self-host on a home connection wants. See
# deploy/README.md.
- DIGMYGEM_YOUTUBE_PROXY_URL=${DIGMYGEM_YOUTUBE_PROXY_URL:-}
- YOUTUBE_RELAY_BASE=${YOUTUBE_RELAY_BASE:-}
- YOUTUBE_RELAY_BASIC_AUTH=${YOUTUBE_RELAY_BASIC_AUTH:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CLAUDE_OAUTH_TOKEN=${CLAUDE_OAUTH_TOKEN:-}
- BYOK_ENCRYPTION_KEY=${BYOK_ENCRYPTION_KEY:-}
# Managed (paid) voices. api/src/routes/tts.ts reads this at request time
# and reports Google TTS unavailable without it, so a key sitting in .env
# and not listed here is a silently dead upsell. Unset, the API boots and
# warns that managed voices are not offered.
- GOOGLE_CLOUD_API_KEY=${GOOGLE_CLOUD_API_KEY:-}
# Read-aloud audio cache. Unset keeps it on disk under DATA_DIR/tts-cache.
- TTS_CACHE=${TTS_CACHE:-}
- TTS_CACHE_MAX_BYTES=${TTS_CACHE_MAX_BYTES:-}
- TTS_CACHE_S3_BUCKET=${TTS_CACHE_S3_BUCKET:-}
- TTS_CACHE_S3_REGION=${TTS_CACHE_S3_REGION:-}
- TTS_CACHE_S3_ENDPOINT=${TTS_CACHE_S3_ENDPOINT:-}
# No colon: the code reads an empty prefix as set.
- TTS_CACHE_S3_PREFIX=${TTS_CACHE_S3_PREFIX-tts-cache/}
# The S3 cache's credentials. Bun's S3 client reads these, not the app.
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
- AWS_SESSION_TOKEN=${AWS_SESSION_TOKEN:-}
# Discover's site fetch and daily digest workers. 1 turns each one on.
- DISCOVER_WORKER=${DISCOVER_WORKER:-0}
- DIGEST_WORKER=${DIGEST_WORKER:-0}
- DIGMYGEM_DIGEST_MAX_CALLS_PER_DAY=${DIGMYGEM_DIGEST_MAX_CALLS_PER_DAY:-}
# Error tracking + tracing (optional). One DSN covers the API and, via
# /__env.js, the browser. Point it at Sentry.io, self-hosted Sentry, or
# GlitchTip. Unset → Sentry stays off.
- SENTRY_DSN=${SENTRY_DSN:-}
- SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-}
- SENTRY_TRACES_SAMPLE_RATE=${SENTRY_TRACES_SAMPLE_RATE:-}
- SENTRY_DEBUG=${SENTRY_DEBUG:-}
# Milliseconds a write transaction may take before the API logs it with the
# call that made it. Unset means 250.
- DIGMYGEM_SLOW_WRITE_MS=${DIGMYGEM_SLOW_WRITE_MS:-}
# One-time repair of document revision history (#828): unset or off does
# nothing, dry-run logs its plan, on deletes. Back up the data directory first.
- DIGMYGEM_REVISION_REPAIR=${DIGMYGEM_REVISION_REPAIR:-}
# On-device dictionaries (lector#438). Unset downloads a dictionary when you
# add its language in the picker. Name languages to pre-fetch them at
# boot, or use `all` for every published language.
- DICT_LANGS=${DICT_LANGS:-}
- DICT_RELEASE_BASE=${DICT_RELEASE_BASE:-}
# DICT_FETCH=0 never downloads; an offline server pre-seeds the files
# (deploy/README.md, "Offline install").
- DICT_FETCH=${DICT_FETCH:-}
# /app/data/dict reads the dictionaries from DATA_PATH instead of the
# named volume above.
- DICT_DIR=${DICT_DIR:-/app/dict}
# Paddle subscriptions (deploy/README.md, "Billing (Paddle)"). Unset is
# billing off; =paddle without PADDLE_API_KEY and PADDLE_WEBHOOK_SECRET
# refuses to boot (docker-entrypoint.sh).
- DIGMYGEM_BILLING=${DIGMYGEM_BILLING:-${LECTOR_BILLING:-}}
# Unset means live Paddle, not sandbox.
- PADDLE_ENV=${PADDLE_ENV:-}
- PADDLE_API_KEY=${PADDLE_API_KEY:-}
- PADDLE_CLIENT_TOKEN=${PADDLE_CLIENT_TOKEN:-}
- PADDLE_WEBHOOK_SECRET=${PADDLE_WEBHOOK_SECRET:-}
- CHECKOUT_URL=${CHECKOUT_URL:-}
- PADDLE_PRICE_MONTHLY=${PADDLE_PRICE_MONTHLY:-}
- PADDLE_PRICE_ANNUAL=${PADDLE_PRICE_ANNUAL:-}
- PADDLE_PRICE_PLUS_MONTHLY=${PADDLE_PRICE_PLUS_MONTHLY:-}
- PADDLE_PRICE_PLUS_ANNUAL=${PADDLE_PRICE_PLUS_ANNUAL:-}
- PADDLE_PRICE_RETIRED_PLUS=${PADDLE_PRICE_RETIRED_PLUS:-}
- PADDLE_DISCOUNT_CODES=${PADDLE_DISCOUNT_CODES:-}
- BILLING_EXEMPT_EMAILS=${BILLING_EXEMPT_EMAILS:-}
# The third tier. Exact lowercase `true` derives a bounded Free plan for
# an authenticated account with no entitled subscription; unset and
# `false` keep the paid-only gate. Without this line an account with no
# subscription resolves `cloud` however the host is configured, so the
# Free the Terms of Service advertises cannot exist. The prerequisites
# above (Turnstile, an http(s) OPENAI_COMPAT_URL and its key) are checked
# at boot; the rest only warn — deploy/README.md, "Free tier".
- DIGMYGEM_FREE_TIER=${DIGMYGEM_FREE_TIER:-${LECTOR_FREE_TIER:-}}
# Plan-limit overrides, as JSON (deploy/README.md, "Free tier").
- DIGMYGEM_PLAN_LIMITS=${DIGMYGEM_PLAN_LIMITS:-${LECTOR_PLAN_LIMITS:-}}
healthcheck:
test:
[
'CMD',
'node',
'-e',
"Promise.all(['http://localhost:3000', 'http://localhost:3457/health'].map((u) => fetch(u).then((r) => { if (!r.ok) throw new Error(u) }))).then(() => process.exit(0), () => process.exit(1))",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
digmygem-dict:.env
These are the settings worth checking. Everything else has a default.
LECTOR_VERSION=latest
API_URL=http://localhost:3457
WEB_PORT=3400
DATA_PATH=./data
DIGMYGEM_MODE=selfhostLECTOR_VERSIONImage tag. latest by default; a pinned version has no v prefix.API_URLThe API address the browser uses. Set it when the browser is not on the server.WEB_PORTHost port for the web interface.DATA_PATHWhere the database and uploads are kept.DIGMYGEM_MODEselfhost for a single user with no login.
Start it
Run these from the folder that holds docker-compose.yml. The first start downloads the images and the translation model, so it can take a few minutes.
cd ~/digmygem
mkdir -p "${DATA_PATH:-./data}" && sudo chown -R 1001:1001 "${DATA_PATH:-./data}"
docker compose pull
docker compose up -dPorts
Web interface: 3400 by default (WEB_PORT). API: 3457, bound to 127.0.0.1 (loopback) on the server. Publish it and set API_URL when the browser is on another machine.
Update
Pull the new image and recreate the container. Your data stays in DATA_PATH.
docker compose pull
docker compose up -d