Deploying the server
Everything Norboten runs on the internet — the API, the database, Redis, the consultant's model, monitoring and the site — lives on one machine and starts with one command. This page is the whole procedure, from an empty account to a server that deploys itself on every push.
Labs never run on the server. They run on each learner's own machine, under QEMU with hardware acceleration; the server only holds accounts, ratings, recordings and the site.
What you need
| A server | Ubuntu 26.04 LTS (24.04 works too), 2 vCPU, 4 GB RAM, 40 GB disk, public IPv4 and IPv6. The smallest rehearsed is 1 vCPU, 2 GB, 20 GB (below). The project runs on a netcup VPS; any VPS or bare-metal box with Ubuntu and root SSH works the same way. Check netcup's current offers for a plan and its price. |
| A domain | Three names point at the server: the apex, api. and status. (plus www., which redirects). |
| On your machine | An SSH key. Only for the laptop route: ansible-core ≥ 2.19 and ansible-galaxy collection install -r ansible/requirements.yml. |
| Optional | A restic repository for off-box backups. No model key: the server uses its own Ollama only. |
The stack
deploy/compose.yaml is the source of truth. The same file runs on the server and, with
compose.local.yaml, on a laptop.
| Service | Image | Job | Reachable at |
|---|---|---|---|
| caddy | caddy:2.10-alpine |
TLS (Let's Encrypt, automatic, HTTP/3) · the static site · reverse proxy | :80, :443 |
| api | ghcr.io/<owner>/norboten-api:<commit> |
FastAPI, 2 uvicorn workers | api.<domain> |
| postgres | postgres:17-alpine |
the database norboten (every table, api/src/norboten_api/db.py) |
compose network only |
| redis | redis:7.4-alpine |
live frames, rate limits, the board cache; no persistence | compose network only |
| ollama | ollama/ollama:0.34.0 |
the consultant's local model, capped at 1 GB, one request and one model at a time, and the first process killed when memory runs out; ollama-pull fetches it once |
compose network only |
| prometheus | prom/prometheus:v3.5.0 |
scrapes the API, the host and Postgres; 30 days | compose network only |
| node-exporter, postgres-exporter | host and database metrics | compose network only | |
| grafana | grafana/grafana:12.1.0 |
dashboards over Prometheus and Postgres, behind a login | status.<domain> |
Only Caddy publishes ports. /metrics on the API returns 404 through Caddy and is read by
Prometheus on the compose network. Server-sent events pass through Caddy unbuffered
(flush_interval -1).
Memory, and what fails first
Ollama is the one part allowed to fail. It is limited to 1 GB without swap, and it carries
oom_score_adj 1000, so when the machine runs out of memory the kernel kills it first; PostgreSQL
(-900), Caddy (-800), the API and Redis (-500) come last. It restarts on its own, and while it is
down the consultant answers with the matching passages instead of a model's reply.
Measured with CPUS=1 MEMORY=2GiB DISK=20GiB make server-rehearsal (arm64, 2026-09-17):
| idle, the model not loaded | 992 MiB of 1,949 available; the API 232 MiB (two workers), Grafana 160, Ollama 36 |
the model loaded (qwen2.5:0.5b) |
Ollama 712 MiB of its 1 GiB; 287 MiB available |
| the nightly backup, the model loaded | at least 307 MiB available |
| a consultant answer on one vCPU | 19–23 s |
| a host process taking memory until the kernel acts | Ollama's model runner and Ollama killed, then the process itself; Ollama restarted once, nothing else restarted, /readyz ready, the site 200, the next answer 18 s |
| disk after the install | 14 of 19 GB used: the images take 10.6 GB, Ollama's 7 GB of it |
On 20 GB, updating Ollama's image needs the old one removed first.
Create the server
netcup has no official Terraform provider, and the community ones manage a server that already exists rather than order one, so the machine is ordered by hand; everything after the first login is the repository's Ansible, run either on the server by the installer or from a laptop.
- Order a VPS in the netcup Customer Control Panel (CCP) and open it in the Server Control Panel (SCP). Under Media → Images, install Ubuntu 26.04 LTS with your SSH public key; note the IPv4 and IPv6 addresses.
- DNS. Point
@,www,apiandstatusat the server with A and AAAA records — in the CCP's DNS management if the domain is registered at netcup, otherwise at its registrar. A CAA record forletsencrypt.orgis optional. Caddy cannot get certificates until the names resolve, so give them a few minutes before installing.
Install with one command, on the server
ssh root@<server>
git clone https://github.com/northelks/norboten.git /opt/norboten-src
python3 /opt/norboten-src/deploy/install-server.py --domain <domain> --email <you@domain> \
--github-client-id <id> --github-client-secret <secret>
The GitHub OAuth App is not optional. Signing in is GitHub and nothing else, so a server without
one comes up and works — and nobody can sign in to it. Create it first (below, "Signing in"). Without
--github-client-id the installer finishes and says so; run it again with both flags once the app
exists — everything else it keeps. Discord is optional: --discord-client-id,
--discord-client-secret, --discord-bot-token and --discord-guild-id turn on linking and the
weekly digest.
deploy/install-server.py needs nothing but Python, which Ubuntu has. It:
- checks the machine (Ubuntu LTS, root, memory, disk) and that every name resolves to it, and asks before going on if one does not;
- installs Ansible into its own virtualenv,
/opt/norboten-installer; - runs
ansible/playbooks/bootstrap.ymlon the server itself: thedeployuser with every key root already accepts, a new key for GitHub Actions (/root/norboten-ci/deploy_ed25519), and passwordless sudo; - runs
ansible/playbooks/server.ymlthe same way (next section); - pulls
ghcr.io/northelks/norboten-api:latest, or builds the image from the clone when nothing has been published yet; - brings the stack up with
deploy.sh, which rolls back if the API never turns ready; - builds the site from the clone into
/opt/norboten/site; - prints the addresses and exactly what to put into GitHub for deploys on push.
The PostgreSQL and Grafana passwords are generated on the first run and kept in
/etc/norboten/install.json (root only); running the installer again keeps them. Everything it runs
is logged to /var/log/norboten-install.log. --dry-run prints the commands without running them,
--no-site skips the site, --build-api builds the image even if one could be pulled.
Before closing the root session, check from a second terminal that ssh deploy@<server> works:
root and password logins are off from then on.
Or from a laptop
The same playbooks, over SSH:
cp ansible/inventory/production.yml.example ansible/inventory/production.yml # address, domain, e-mail
make bootstrap # as root, once; KEY=path/to/key.pub for another key
cd ansible
cp group_vars/all/vault.yml.example group_vars/all/vault.yml
$EDITOR group_vars/all/vault.yml # openssl rand -base64 32 for each password
ansible-vault encrypt group_vars/all/vault.yml
ansible-playbook -i inventory/production.yml playbooks/server.yml --ask-vault-pass \
-e norboten_api_tag=latest
What the playbooks set up
server_base hardens the host — no root or password SSH, ufw with only 22/tcp, 80/tcp, 443/tcp and
443/udp open, fail2ban, unattended security upgrades — and installs Docker Engine and the compose
plugin from Ubuntu's own archive. norboten_stack copies deploy/ to /opt/norboten, writes
/opt/norboten/.env (mode 0600), runs deploy.sh, and installs the nightly backup and analytics
timers. The first run pulls about 3 GB of images; Ollama then downloads its model in the background.
Backups are the nightly database dumps below, copied off the machine by restic when a target is set;
netcup's own snapshots, taken in the SCP, are an optional extra.
Check it:
curl https://api.<domain>/readyz # {"status":"ready","checks":{"database":"ok","redis":"ok"}}
curl -I https://<domain>/
ssh deploy@<server> 'cd /opt/norboten && sudo docker compose ps'
Signing in, Discord, announcements and the digest
Signing in: the GitHub OAuth App
GitHub → Settings → Developer settings → OAuth Apps → New OAuth App:
- Homepage URL
https://<domain>, Authorization callback URLhttps://api.<domain>/auth/github/callback— an OAuth App has exactly one callback, so a laptop stack needs a second app ("Norboten (local)", callbackhttp://localhost:8000/auth/github/callback); - tick Enable Device Flow: the terminal signs in with it;
- no scopes are requested, ever; generate a client secret.
Put the client id and secret in the vault as norboten_github_client_id and
norboten_github_client_secret (or pass them to the installer), then re-run server.yml. Secrets go
into the vault or to the installer, never into a chat or a commit.
Discord: linking and the digest (optional)
Discord Developer Portal → New Application:
- OAuth2 → Redirects:
https://api.<domain>/auth/discord/callback(andhttp://localhost:8000/auth/discord/callbackfor a laptop); - Bot → reset and copy the token; no privileged intents are needed — the bot never connects to the gateway, it only sends direct messages over REST;
- OAuth2 → URL Generator, scope
bot, permissions Create Instant Invite and Send Messages: open the URL and add the bot to the Norboten server; - the server's id (Developer Mode on, right-click the server, Copy Server ID).
Vault: norboten_discord_client_id, norboten_discord_client_secret, norboten_discord_bot_token,
norboten_discord_guild_id; re-run server.yml. The account page then offers Connect Discord
(with "join the Norboten server", off by default), and the Sunday digest timer turns itself on.
Announcements and donations
These are built in and off until they have somewhere to send to:
| What | Where it is set |
|---|---|
| Telegram: a live session going public | norboten_telegram_bot_token, norboten_telegram_chat (Ansible vault), re-run server.yml |
| The Sunday learner digest | nothing more: the timer turns itself on once norboten_discord_bot_token is set |
| Discord and Telegram release announcements | GitHub secrets DISCORD_WEBHOOK, TELEGRAM_BOT_TOKEN and variable TELEGRAM_CHAT |
| Donations on the site | norboten_stripe_secret_key (Ansible vault), re-run server.yml. Without it the donate page says card donations are off on this deployment and shows the other ways to help; nothing else on the site changes |
Donations. The API creates one Stripe Checkout Session per click (POST /donations/checkout)
with an inline price, so no product or price has to exist in the Stripe account, and the donor
finishes on Stripe's own page: no card detail ever reaches this server, and nothing about a
donation is stored here. The tiles are €5, €10, €15, €20, €25 and €50, monthly or one-off, and a
free amount between €2 and €5000; site/build.py (DONATION_TIERS) and
api/src/norboten_api/routers/donate.py (TIERS) have to agree. Rehearse with an sk_test_… key
and Stripe's test cards before putting the live one in the vault.
Deploy on every push
.github/workflows/deploy.yml does, on each push to main:
- test — ruff, the whole pytest suite with PostgreSQL and Redis as service containers, and the node checks for the terminal player and the consultant's fallback;
- image — builds
api/Dockerfilefor amd64 and arm64 and pushesghcr.io/<owner>/norboten-api:<commit>and:latest; - site — generates the sample population and builds
site/distagainsthttps://api.<domain>; - rollout — rsyncs
deploy/andsite/distto the server over SSH, runssudo /opt/norboten/deploy.sh <commit>, and checks/readyzand the site from outside.
It needs, in the repository's settings:
| Kind | Name | Value |
|---|---|---|
| variable | SERVER_HOST |
the server's address; without it the rollout job is skipped |
| variable | SERVER_HOST_KEY |
ssh-keyscan -t ed25519 <server> — pinned, never trusted on first use |
| variable | NORBOTEN_DOMAIN |
e.g. norboten.org |
| secret | DEPLOY_SSH_KEY |
the private key the installer made (/root/norboten-ci/deploy_ed25519; delete the file afterwards), or the one you gave make bootstrap |
| environment | production |
add required reviewers here if a push should wait for approval |
The GHCR package must be public, or the server must docker login ghcr.io once with a read-only
token.
Rollback
deploy.sh <tag> pulls the image, restarts the API, and waits up to two minutes for /readyz. If
it never turns ready, it puts the previous tag back, restarts that, and exits non-zero — so a bad
image fails the workflow and leaves the previous version serving. The tag in use is in
/opt/norboten/.deployed, the one before it in .deployed.previous.
To go back deliberately:
- from GitHub — Actions → deploy → Run workflow →
api_tag= the commit to return to. It checks out that commit'sdeploy/, skips the build, and rolls out the image already in GHCR; - on the server —
sudo /opt/norboten/deploy.sh $(cat /opt/norboten/.deployed.previous).
The database schema only ever grows (every statement in db.SCHEMA is IF NOT EXISTS), so an
older API runs against a newer database.
Backups
/opt/norboten/backup.sh runs nightly at 03:20 (norboten-backup.timer):
pg_dump -Fcofnorboteninto/var/backups/norboten, on the server's disk;- restores the dump into a scratch database and counts users and attempts — a dump that does
not restore fails the run, and
systemctl status norboten-backupshows it; - deletes dumps older than 14 days;
- with
BACKUP_TARGETset, copies the directory off-box with restic and keeps 14 daily and 8 weekly snapshots.
Restore:
sudo /opt/norboten/restore.sh /var/backups/norboten/norboten-<stamp>.dump
Redis is not backed up: nothing in it has to survive. Ollama's model re-downloads. Caddy's certificates are re-issued if the disk is lost.
Operations
| Task | Command, in /opt/norboten |
|---|---|
| status | sudo docker compose ps |
| logs | sudo docker compose logs -f --tail 100 api |
| a shell in the database | sudo docker compose exec postgres psql -U norboten |
| restart one service | sudo docker compose restart api |
| change a secret | edit the vault, rerun the playbook |
| a larger model | OLLAMA_MODEL=qwen2.5:3b in the vault, rerun, and set NORBOTEN_CONSULTANT_MODELS |
| disk | sudo docker system df; images from old deploys are pruned on every successful deploy |
| a manual backup | sudo ./backup.sh |
| redraw the Analytics page now | sudo systemctl start norboten-analytics (nightly at 04:10 otherwise) |
| send the learner digest now | sudo systemctl start norboten-digest (Sundays at 18:00 otherwise; only with norboten_discord_bot_token set). sudo docker compose --profile jobs run --rm digest python -m norboten_api.digest --dry-run prints it instead |
Grafana (status.<domain>, user admin) has Prometheus and the Norboten database provisioned as
data sources, and two dashboards in the folder Norboten, both provisioned from
deploy/grafana/provisioning/dashboards — they come back as they are on every restart, so edits
made in the browser are not kept:
| Dashboard | Datasource | What it answers |
|---|---|---|
| Service | Prometheus | Is the API up and how fast is it (rate by status, median/p95/p99, the five slowest route templates), can Prometheus reach every target, how the host is doing — and how much room is left on /, which is the first thing to fail on a 20 GB server |
| Norboten | Postgres | What learners did: accounts, attempts by day passed against failed, the labs being worked on, the events the API wrote down, ratings by topic. The sample population is excluded everywhere (users.seed), so these are real accounts only |
Streams keep working through an API restart: viewers' browsers reconnect and replay from the recording.
Everything on a laptop
make stack-up runs the same compose file with compose.local.yaml: the API built from your
checkout, the site from site/dist, plain HTTP, the sample population loaded, and the debug
identity accepted. The site is on http://localhost:8080, the API on http://localhost:8000,
Grafana on http://localhost:3000. Point the TUI at it with
NORBOTEN_API=http://localhost:8000 uv run norboten. make stack-down stops it; V=1 deletes its
data too.
Lab VMs still run on the host under QEMU, never inside Docker — there is no nested virtualization for QEMU on macOS, and a lab needs a real kernel.
Rehearsing the deploy
make server-rehearsal rehearses all of the above on a local Ubuntu LTS VM (Lima, 4 CPUs, 6 GB; CPUS, MEMORY and DISK size a new one): the
production playbook configures it — hardening, firewall, Docker, the compose project, the timers —
and the same deploy.sh rolls the API out. Three things differ, the three a laptop cannot have:
the API image is built locally and loaded into the VM instead of pulled from GHCR, the domain is
norboten.test, and Caddy issues certificates from its own CA (CADDY_GLOBAL=local_certs)
instead of Let's Encrypt. When it finishes it prints the API's readiness and the site's status
through Caddy on https://norboten.test:8443 (curl with -k and --resolve). make server-rehearsal-down deletes the VM.
The first rehearsal found two faults a real first deploy would have hit, both fixed: a compose restart ran before the API image existed, and one image that could not be pulled aborted the pulls of all the others.
Hosted labs
Running learners' lab VMs on the server would need /dev/kvm. Cloud VPS instances do not offer
nested virtualization, so that is only possible on bare metal (a dedicated root server), and it is
not built.