[ norboten ]

Self-hosting and configuration

Three ways to run the server side yourself, from least to most:

Command What you get
The API alone uv run python -m uvicorn norboten_api.main:app --reload the API on :8000 with everything in memory — fine for developing the TUI or the API
The whole stack on a laptop make stack-up the production compose project with a local override: site :8080, API :8000, Grafana :3000, sample data loaded
A real server Deploying the server the same compose project on an Ubuntu machine with TLS, backups and deploy-on-push

Then point the TUI at it:

NORBOTEN_API=http://localhost:8000 uv run norboten

The boards, live sessions, signing in and the consultant then use that server. Without NORBOTEN_API, the TUI uses https://api.norboten.org; without any reachable server, the labs, theory, journals, local recordings, the tutor and the review all still work — those two run on the learner's machine — and the Ratings and You sections say they are offline.

The API's settings

Every setting is an environment variable with the NORBOTEN_ prefix (api/src/norboten_api/settings.py).

Variable Default Meaning
NORBOTEN_DATABASE_URL memory:// postgresql://user:password@host:5432/db; memory keeps everything in the process
NORBOTEN_REDIS_URL (empty) redis://host:6379/0; empty keeps live frames and rate limits in the process — only correct with one worker
NORBOTEN_REQUIRE_AUTH false true on a server: every signed endpoint needs a token; false also accepts X-Debug-User
NORBOTEN_SITE_URL https://norboten.org the site, for the links the API hands out (the account page, the MCP sign-in page)
NORBOTEN_API_URL https://api.norboten.org the API's own public address: the OAuth issuer, and <api>/mcp is the MCP server's resource identifier
NORBOTEN_MCP_PER_MINUTE 120 MCP requests per client per minute
NORBOTEN_MCP_STATE_KEY (empty) seals a quiz_me question between its two requests; every worker needs the same one, and empty (a key per process) is right with one worker only
NORBOTEN_TELEGRAM_BOT_TOKEN, NORBOTEN_TELEGRAM_CHAT (empty) a real session going live is announced to this chat (announce.py); empty sends nothing
NORBOTEN_GITHUB_CLIENT_ID, NORBOTEN_GITHUB_CLIENT_SECRET (empty) required to sign anyone in: the GitHub OAuth App (callback <api>/auth/github/callback, Enable Device Flow ticked). Empty and every sign-in endpoint answers 503 "sign-in is not configured on this server"
NORBOTEN_GITHUB_URL, NORBOTEN_GITHUB_API_URL https://github.com, https://api.github.com GitHub's two hosts; only a rehearsal points them at a stub
NORBOTEN_SIGN_INS_PER_MINUTE 10 sign-ins one client may start, and one-time codes it may exchange, per minute
NORBOTEN_DISCORD_CLIENT_ID, NORBOTEN_DISCORD_CLIENT_SECRET, NORBOTEN_DISCORD_BOT_TOKEN (empty) optional: linking Discord on the account page, and the weekly digest as a direct message from the bot. Empty and Discord is hidden and no digest is sent
NORBOTEN_DISCORD_GUILD_ID (empty) the Norboten Discord server that "join the server" adds a member to
NORBOTEN_DISCORD_API_URL, NORBOTEN_DISCORD_AUTHORIZE_URL Discord's only a rehearsal changes them
NORBOTEN_ALLOWED_ORIGINS https://norboten.org,https://www.norboten.org browser origins allowed by CORS
NORBOTEN_OLLAMA_URL (empty) http://ollama:11434; empty means no local model
NORBOTEN_CONSULTANT_MODELS ollama/qwen2.5:0.5b tried in order; the first this server can reach answers the consultant
NORBOTEN_CHAT_PER_MINUTE 20 consultant questions per client per minute
NORBOTEN_TELEMETRY_ENABLED true accept opt-in stuck-point telemetry
NORBOTEN_RATED_DIR (empty) the rated labs and banks, mounted read-only (/app/rated in the compose file); empty or missing means this server has none — a self-hosted server has no rated content unless it brings its own
NORBOTEN_STRIPE_SECRET_KEY (empty) donations: sk_live_…, or sk_test_… to rehearse. Empty and the donate page says the card path is off on this deployment
NORBOTEN_DONATIONS_PER_MINUTE 6 Checkout Sessions one client may start per minute

The server uses Ollama models only (ollama/<name>); it reads no hosted model key, so a key in its environment changes nothing.

The stack's settings

deploy/.env on a server (written by Ansible from the vault), deploy/.env.local on a laptop (written by make stack-up with fresh passwords). See deploy/.env.example.

Variable Meaning
NORBOTEN_DOMAIN the apex; api., status. and www. are derived
ACME_EMAIL Let's Encrypt's contact for expiry notices
API_IMAGE, API_TAG the API image; deploy.sh rewrites the tag
API_WORKERS uvicorn workers, default 2
POSTGRES_PASSWORD, GRAFANA_ADMIN_PASSWORD required; openssl rand -base64 32
MCP_STATE_KEY the MCP server's shared state key (NORBOTEN_MCP_STATE_KEY); install-server.py generates one
API_URL overrides NORBOTEN_API_URL; default https://api.<domain>
OLLAMA_MODEL the model ollama-pull fetches; default qwen2.5:0.5b
OLLAMA_MEMORY Ollama's memory limit, default 1g; past it only Ollama is killed, and it restarts. It runs one request and keeps one model loaded at a time, with a 4,096-token context
TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT live sessions announced by the API; empty sends nothing
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET the GitHub OAuth App; without it nobody can sign in. A laptop stack needs an app of its own, with the callback http://localhost:8000/auth/github/callback, and without one uses the debug identity
DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN, DISCORD_GUILD_ID optional: Discord linking and the Sunday digest; without the bot token the digest timer stays off
CONSULTANT_MODELS overrides NORBOTEN_CONSULTANT_MODELS
SITE_DIR where Caddy serves the site from, default ./site
RATED_DIR the rated content on the host, default /opt/norboten/rated; mounted into the API read-only, never into the image
BACKUP_DIR, BACKUP_KEEP_DAYS local dumps, default /var/backups/norboten, 14
BACKUP_TARGET, RESTIC_PASSWORD an off-box restic repository, optional
CADDY_GLOBAL extra Caddy global options; the rehearsal sets local_certs

The site's settings

site/build.py reads three variables; all three are empty or defaulted in a plain checkout, so a self-hosted build ships no third-party script at all.

Variable Default Meaning
NORBOTEN_SITE_API https://api.norboten.org the API the pages call for ratings, live sessions and the consultant
NORBOTEN_SITE_URL https://norboten.org the site's own address: canonical links, Open Graph and the JSON-LD
NORBOTEN_SITE_GA_ID (empty) a GA4 measurement ID (G-…) adds the analytics tag; empty adds no script and sets no cookie

The client's settings

Variable Meaning
NORBOTEN_API the API the TUI talks to
NORBOTEN_HOME where everything local is kept, default ~/.norboten (keep the path short: UNIX socket paths are limited to 104 bytes)
NORBOTEN_LABS_DIR labs from a directory instead of the cache
NORBOTEN_IMAGE_MIRROR a directory or URL of locally built golden images
NORBOTEN_LAB_REGISTRY the OCI repository prefix labs are pulled from
NORBOTEN_DEBUG_USER act as this user against an API with NORBOTEN_REQUIRE_AUTH=false
ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY optional: models for the tutor, the review and drafting your own questions (g on Theory), after Claude Code
OLLAMA_HOST an Ollama for the same, last in order; without it the TUI looks for one on 127.0.0.1:11434
NORBOTEN_CLAUDE_BIN the Claude Code binary for claude-code/… model ids, default claude

Choosing models

The server never calls a hosted model, and runs only one: the consultant's, on Ollama. The tutor and the review of a finished attempt run in the TUI on the learner's own model — Claude Code on their subscription, then an exported key, then a local Ollama, or the one pinned with m on System — and with none of them t shows each lab's four-level hint ladder. Theory questions are not generated on the server at all: they are drafted on a maintainer's machine on a Claude subscription and published in the repository's banks (docs/quiz-spec.md §5).

A 0.5B model answers the consultant in seconds on a CPU, and its answers are plain. Most of what makes an answer right is the retrieval in front of it: on the ten questions in api/tests/consultant_eval.py, qwen2.5:0.5b gets 8 right and qwen2.5:1.5b 9, and the two it misses are numbers it misreads from the passage it was given. OLLAMA_MODEL and CONSULTANT_MODELS can name a larger one — qwen2.5:1.5b needs about 1.4 GB of memory while loaded, more than Ollama's 1 GB limit (raise OLLAMA_MEMORY on a server with room for it), and qwen2.5:3b about 4 GB to itself. Run the eval against the model before switching.