# Self-hosting

The technical version of the [Host it yourself](/self-host) guide. The
guide walks a first-timer through a Raspberry Pi Zero 2 W; this page has the
details behind each step, for any 64-bit Linux machine. Stuck? The
[self-hosting FAQ](self-hosting-faq) answers the first questions people ask.

Self-hosting is free. Help is best-effort, in
[GitHub Discussions](https://github.com/alexbeeken/pooki/discussions); the
hosted site is the supported option.

## Skrongle mini

The release you install is **Skrongle mini**: the same binary as the hosted
site, with `skrongle.service` set for a handful of people who know you and
no outside services. What it leaves out:

- **No e-mail or text messages.** Links that would be mailed are shown in
  the page ([Without e-mail or text messages](#without-e-mail-or-text-messages)).
- **No plans or payments.** Everyone has everything (`SKRONGLE_PLANS=off`).
- **Sign-up by invitation.** The first account is yours, the owner's;
  everyone else needs a link you make (`SKRONGLE_SIGNUP=invite`,
  [Accounts and AI keys](#accounts-and-ai-keys)).
- **Your own Terms.** `SKRONGLE_TERMS=/etc/skrongle/terms.md`
  ([Your own Terms](#your-own-terms)).
- **Background work in process.** The mail and text outboxes and the
  background jobs are rows in SQLite, run by threads in the server: no
  Redis or queue service.
- **SQLite only.** One database file in `/var/lib/skrongle`, no database
  server.

The startup log line and `/healthz` say `skrongle mini <version>` while
`SKRONGLE_PLANS=off` (`skrongle <version>` otherwise).

### Security

You secure the login, as with any self-hosted app:

- A strong password for every account.
- Two-factor sign-in with an authenticator app (*Profile → Two-factor
  authentication*). It needs no e-mail or texts.
- The server reachable only over Tailscale or a VPN
  ([Reaching it from outside](#reaching-it-from-outside)). **Never forward
  a port** on the router.

What the app covers by itself: argon2id password hashes; sign-in limits (30
attempts per address per 10 minutes, 10 wrong passwords per account per 15
minutes) and registration limits; slow or oversized requests cut off; a
connection cap. What it doesn't: HTTPS (it speaks plain HTTP; Tailscale
encrypts the link), a lockout or an alert after many guesses, attackers
guessing from many addresses at once, and updates (install new releases
yourself, [Updating](#updating)).

- Already run a sign-in proxy (Authelia, Tailscale Serve)? Let it sign
  people in: [Signing in at the proxy](#signing-in-at-the-proxy).
- Locked yourself out? The binary resets a password or two-factor from the
  shell: [If you lock yourself out](#if-you-lock-yourself-out).

## What a release contains

Each [GitHub release](https://github.com/alexbeeken/pooki/releases) has one
tarball per architecture, named `skrongle-<version>-<arch>.tar.gz`, where
`<arch>` is what `uname -m` prints:

| File | For |
|---|---|
| `skrongle-<version>-aarch64.tar.gz` | 64-bit ARM: Raspberry Pi Zero 2 W, 3, 4, 5 on the 64-bit OS |
| `skrongle-<version>-x86_64.tar.gz` | PCs, mini PCs, most VPSs |

Each comes with a `.sha256` file. A tarball unpacks (flat, no top folder) to:

| Path | What it is |
|---|---|
| `skrongle` | The server: one binary. Templates, CSS, scripts and these docs are compiled in. |
| `data/courses/`, `data/themes/` | The built-in courses and themes, read from disk when the server starts (`SKRONGLE_DATA_DIR`). |
| `skrongle.service` | The systemd unit below (`deploy/skrongle.service` in the repo). |
| `terms.example.md` | A template for your own Terms of Service (`deploy/terms.example.md`); see [Your own Terms](#your-own-terms). |
| `update.sh` | Updates the install in place: see [Updating](#updating). |
| `VERSION` | The version, one line. |

Everything a learner creates (accounts, progress, courses made in the app)
is in the SQLite database, not in these files, so unpacking a new release
over the old one keeps it.

The `.sha256` file next to each tarball is its SHA-256 sum. The install
command below downloads it and checks the tarball against it (`sha256sum -c`
prints `OK`); stop if it doesn't. `update.sh` does the same check.

## Requirements

- 64-bit Linux with glibc 2.31 or newer: Raspberry Pi OS Bookworm or later,
  Debian 11+, Ubuntu 20.04+. On a Pi, use the **64-bit** OS (`aarch64`).
- No other services: there is no separate database, web server or runtime.
- About 64 MB of memory for the service (the unit's hard limit).

## Install

Pick the version from the [releases page](https://github.com/alexbeeken/pooki/releases)
(the commands below use `0.2.0`; `uname -m` picks the architecture):

```sh
V=0.2.0
curl -LO https://github.com/alexbeeken/pooki/releases/download/v$V/skrongle-$V-$(uname -m).tar.gz
curl -LO https://github.com/alexbeeken/pooki/releases/download/v$V/skrongle-$V-$(uname -m).tar.gz.sha256
sha256sum -c skrongle-$V-$(uname -m).tar.gz.sha256
sudo mkdir -p /opt/skrongle
sudo tar -xzf skrongle-$V-$(uname -m).tar.gz -C /opt/skrongle
sudo mkdir -p /etc/skrongle
sudo cp -n /opt/skrongle/terms.example.md /etc/skrongle/terms.md
sudo cp /opt/skrongle/skrongle.service /etc/systemd/system/
sudo systemctl enable --now skrongle
```

Then open `http://<hostname>.local:8080`, or `http://<ip>:8080` where `<ip>`
is the first address `hostname -I` prints.

To try it without systemd, run it from its folder:
`cd /opt/skrongle && SKRONGLE_DB=$HOME/learn.db ./skrongle`.

## Diagnostics: `skrongle doctor`

`skrongle doctor` prints one screen about the install, to read yourself or
paste into a bug report. It shows the version, OS and architecture, the
device model, the listen address and whether the port is free, the data
folder and how many courses and themes it holds, the database path, size
and schema (migration) level, and whether the database folder is writable.
It also shows whether a secret key, Google sign-in and subscriptions are
configured, how many AI keys learners have saved, and free disk and memory.
Secrets show as yes/no only, never their values. It only reads. It opens
the database read-only and creates nothing.

It reads the same `SKRONGLE_*` settings as the server. To see what the systemd
service sees, give it the unit's settings and its env file:

```sh
sudo env $(systemctl show skrongle -p Environment --value) /opt/skrongle/skrongle doctor --env-file /etc/skrongle/env
```

A line that starts with `PROBLEM:` says what to fix.

## The systemd unit

`deploy/skrongle.service` holds the Skrongle mini defaults:

- runs `/opt/skrongle/skrongle` as a user systemd creates for it
  (`DynamicUser=yes`), so there is no `useradd` step;
- keeps the database in `/var/lib/skrongle` (`StateDirectory=skrongle`);
- listens on port 8080 on every interface;
- restarts it if it stops (`Restart=on-failure`);
- caps memory at 64 MB (`MemoryMax`) and sandboxes the process
  (`ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, …);
- shows your Terms from `/etc/skrongle/terms.md` (`SKRONGLE_TERMS`; it will
  not start without that file, see [Your own Terms](#your-own-terms));
- lets people sign up only with an invitation from you
  (`SKRONGLE_SIGNUP=invite`, see [Accounts and AI keys](#accounts-and-ai-keys));
- turns plans and payments off (`SKRONGLE_PLANS=off`, see
  [Accounts and AI keys](#accounts-and-ai-keys));
- reads optional settings from `/etc/skrongle/env`.

Useful commands:

```sh
systemctl status skrongle          # running? since when?
journalctl -u skrongle -f          # the log, live
sudo systemctl restart skrongle    # after changing /etc/skrongle/env
```

## Configuration

Every setting is an environment variable. Put extra ones in
`/etc/skrongle/env`, one `NAME=value` per line, and restart. Every name
starts `SKRONGLE_`; see [Renamed settings](#renamed-settings) if yours start `LEARN_`.

"mini" marks what `skrongle.service` sets for Skrongle mini.

| Variable | Default | Meaning |
|---|---|---|
| `SKRONGLE_PLANS` | `on` (mini: `off`) | `off`: no plans, prices or payments; every account has every feature and the server's AI key is shared. See [Accounts and AI keys](#accounts-and-ai-keys). Keep it `off`: it is what makes the server a mini. Without it the server is the hosted build, which refuses to start without e-mail, texts, Google and Stripe. |
| `SKRONGLE_BIND` | `0.0.0.0:8080` | Address and port to listen on; a bare port (`8081`) means every interface. |
| `SKRONGLE_SIGNUP` | `open` (mini: `invite`) | Who may make an account: `open`, `invite` or `closed`; see [Accounts and AI keys](#accounts-and-ai-keys). |
| `SKRONGLE_PROXY_AUTH_HEADER`, `SKRONGLE_PROXY_AUTH_FROM` | — | Let the proxy in front sign people in; see [Signing in at the proxy](#signing-in-at-the-proxy). Both or neither. |
| `SKRONGLE_DATA_DIR` | `data` | Folder with `courses/` and `themes/`. |
| `SKRONGLE_DB` | `learn.db` | SQLite database path. The secret key is created next to it as `<db>.key`. |
| `SKRONGLE_SECRET_KEY` | (from `<db>.key`) | 64 hex characters; encrypts saved AI keys. Set it to keep the secret out of the data folder. |
| `SKRONGLE_NEW_SECRET_KEY` | off | `1` only when the secret key is lost for good: see [Backup and restore](#backup-and-restore). |
| `SKRONGLE_OPENAI_BASE_URL` | `https://api.openai.com` | Point the OpenAI provider at any OpenAI-compatible server, e.g. Ollama on another machine. |
| `SKRONGLE_ALLOW_PRIVATE_AI_URLS` | off | `1` lets learners type a base URL on your own network (e.g. `http://192.168.1.20:11434`) into the AI forms. Off, the server refuses to call private, loopback and link-local addresses for them, and ports other than 443 and 80. |
| `SKRONGLE_AI_JOB_BUDGET_SECS` | `3600` | How long one AI generation job may run in all. Raise it for a slow self-hosted model. |
| `SKRONGLE_MAX_CONNECTIONS` | `256` | Open connections the server allows; more clients wait until one closes. Keep it well under the unit's `LimitNOFILE` (1024). |
| `SKRONGLE_LOG` | `info` | `error`, `warn`, `info`, `debug` or `trace`. |
| `SKRONGLE_SECURE_COOKIES` | off | `1` when the site is served over HTTPS (behind a TLS proxy). |
| `SKRONGLE_PUBLIC_URL` | — | The address people use, e.g. `https://learn.example.org`. Needed for Google sign-in. |
| `SKRONGLE_GOOGLE_CLIENT_ID`, `SKRONGLE_GOOGLE_CLIENT_SECRET` | — | Optional "continue with Google". Needs `SKRONGLE_PUBLIC_URL`. |
| `SKRONGLE_TERMS` | `builtin` (mini: `/etc/skrongle/terms.md`) | Your Terms of Service: the path of a Markdown file (the unit sets `/etc/skrongle/terms.md`), or `builtin` for the hosted service's terms. See [Your own Terms](#your-own-terms). |
| `SKRONGLE_LEGAL_EMAIL` | — | With `SKRONGLE_TERMS=builtin` only: your copyright / legal contact, shown on `/terms` in place of `[CONTACT EMAIL]`. |
| `SKRONGLE_MIN_AGE` | `14` | The minimum age the registration checkbox states. |
| `SKRONGLE_FAILED_COURSE_RETENTION_DAYS` | `30` | An AI course whose generation failed is deleted, with the notes it was given, after this many days without a resume or edit. `0` keeps them. |
| `SKRONGLE_COURSES_PER_USER` | `50` (no limit in dev mode) | Courses one account may make. "Create a course" says so when an account has them all. |
| `SKRONGLE_COURSE_BYTES` | `8388608` (8 MB) | Total size of one course's files (the Spanish course is 3.7 MB). A save, upload or lesson-table change past it is refused and stores nothing. |
| `SKRONGLE_COURSE_BYTES_PER_USER` | `33554432` (32 MB) | Total size of the files of all the courses one account owns. Undo copies are not counted. |
| `SKRONGLE_MAIL_FROM`, `SKRONGLE_MAIL_PROVIDERS` | — | Sending e-mail; see [E-mail](#e-mail). Unset, messages are only logged. |
| `SKRONGLE_MAIL_PER_HOUR` | `3000` | The most e-mails of one kind (address confirmations, sign-in codes, course invitations, …) due in any hour; each kind has its own budget. Past it a message waits for the next hour with room instead of being sent at once (a sign-in code, which would have expired, is not queued and the page says so). `0` turns the ceiling off. See [E-mail](#e-mail). |
| `SKRONGLE_MAIL_WINDOW` | `3600` | The ceiling's window in seconds: `SKRONGLE_MAIL_PER_HOUR` counts per window, and a capped message waits for the next one. Shorten it only to watch the delay while testing. |
| `SKRONGLE_SMS_FROM`, `SKRONGLE_SMS_PROVIDERS` | — | Sending text messages; see [Text messages](#text-messages). Unset, texts are only logged. |
| `SKRONGLE_SMS_COUNTRIES`, `SKRONGLE_SMS_MAX_PER_HOUR`, `SKRONGLE_SMS_MAX_PER_DAY` | see [Text messages](#text-messages) | Where texts may go and how many the server sends. |

[Architecture](architecture) lists the rest (subscriptions, developer
settings).

### Renamed settings

Settings used to start `LEARN_` (and before that `SPANISH_`). They now
start `SKRONGLE_`: `LEARN_DB` is `SKRONGLE_DB`, `LEARN_MAIL_FROM` is
`SKRONGLE_MAIL_FROM`, and so on. The rest of the name is the same.

This release still reads the old names, so an upgrade keeps working. At
startup the log has one warning listing each old name it found and its new
name. If both spellings are set, the `SKRONGLE_` one wins and the warning
says so. The next release stops reading the old names, so rename them now:

```sh
sudo sed -i 's/^LEARN_/SKRONGLE_/; s/^SPANISH_/SKRONGLE_/' /etc/skrongle/env
sudo sed -i 's/=LEARN_/=SKRONGLE_/' /etc/systemd/system/skrongle.service
sudo systemctl daemon-reload && sudo systemctl restart skrongle
```

Or copy the new `skrongle.service` from the release over yours, if you
have not edited it.

## Your own Terms

Everyone who makes an account on your server accepts its Terms of Service,
at sign-up and again whenever they change. The text is yours: you run the
server, so the hosted service's terms do not fit. The release ships a short
plain-language template, `terms.example.md`. Copy it and put your name in:

```sh
sudo mkdir -p /etc/skrongle
sudo cp -n /opt/skrongle/terms.example.md /etc/skrongle/terms.md
sudo nano /etc/skrongle/terms.md     # fill in every [BRACKET]
sudo systemctl restart skrongle
```

`/terms` then shows that file as it is. Its SHA-256 is the version, so after
any edit (and a restart) every account is asked to accept again at its next
visit, on a plain page with the accept box. There is no summary of what
changed and no e-mail: the hosted site's notices and 30-day e-mail of a
change come from its changelog, which your file does not have. If the file
is missing, unreadable or empty the server refuses to start, and the log
names the path. `SKRONGLE_TERMS=builtin` shows the hosted service's terms
instead. The age box ("I am at least 14") stays either way
(`SKRONGLE_MIN_AGE`).

## E-mail

The app writes every message to an outbox in its database and a background
thread sends it. `SKRONGLE_MAIL_PROVIDERS` lists one or more providers in the
order to try them; if one cannot be reached or refuses a message, the next
gets it. A provider that could not be reached is tried last for five minutes.
A message nobody took is retried after 1 minute, 5 minutes, 30 minutes, 2 and
12 hours, then marked failed; one that every provider refused (a bad
address) fails at once. Restarts lose nothing. In dev mode, without
providers, mail goes to the app's own dev mailbox at `/dev/inbox`
([testing](testing#e-mail-the-dev-mailbox)).

Two caps protect your sender domain's reputation, since anyone can register
with any address. One address gets at most 10 address-confirmation e-mails
a day, whichever account asks for them (deleting the account and
registering again does not reset this); past that, nothing is sent. And
at most `SKRONGLE_MAIL_PER_HOUR` e-mails of each kind (default 3000) are due
in any clock hour. Each kind has its own budget, so a burst of course
invitations never holds back a confirmation e-mail or a sign-in code. The
app does not drop a message past the ceiling. It queues the message for the
next hour with room, up to 12 hours ahead, and sends it then. Sign-in codes
are the exception. They expire in 10 minutes, so the app does not queue one
past the ceiling and the page says it could not be sent. Set the ceiling to what your
provider's plan allows (a plan for about 10,000 messages a day suits 3000),
or `0` to turn it off. Dev mode has no hourly ceiling unless you set one.
The log says when a message waits (`mail #12 (email-verify) waits 1800s`)
and when a cap holds one back.

Each provider is named in `SKRONGLE_MAIL_PROVIDERS` and configured with
`SKRONGLE_MAIL_<NAME>_…`. `_KIND` says what it is (`smtp`; defaults to the
name). Any SMTP submission service works: Amazon SES, Postmark, Resend,
Brevo, Mailgun.

| Variable | Default | Meaning |
|---|---|---|
| `SKRONGLE_MAIL_<NAME>_HOST` | — | SMTP server. |
| `SKRONGLE_MAIL_<NAME>_TLS` | `starttls` | `starttls` (port 587), `tls` (port 465), or `none` for a server on this machine only. |
| `SKRONGLE_MAIL_<NAME>_PORT` | from `_TLS` | |
| `SKRONGLE_MAIL_<NAME>_USER`, `_PASS` | — | SMTP credentials; both or neither. |

HTTP API providers (`_KIND=resend`, `postmark`, `brevo`) send over HTTPS (port 443)
instead, so they work where outbound SMTP is blocked, and their errors
tell a bad address (refused at once) from an outage or a bad key (retried,
next provider). The message's purpose goes to the provider as its tag.

| Variable | Default | Meaning |
|---|---|---|
| `SKRONGLE_MAIL_<NAME>_KIND` | the name | `resend`: [Resend](https://resend.com)'s API. `postmark`: [Postmark](https://postmarkapp.com)'s API. `brevo`: [Brevo](https://brevo.com)'s API. |
| `SKRONGLE_MAIL_<NAME>_API_KEY` | — | Required. Resend: an API key (`re_…`) with sending access. Postmark: the Server API token. Brevo: an API key (`xkeysib-…`, SMTP & API → API Keys). |
| `SKRONGLE_MAIL_<NAME>_API_URL` | the provider's (`https://api.resend.com`, `https://api.postmarkapp.com`, `https://api.brevo.com/v3/smtp/email`) | Where API calls go, for pointing tests at a mock. `https://`, or `http://` on this machine. |
| `SKRONGLE_MAIL_<NAME>_STREAM` | `outbound` | `postmark` only: the message stream. |

Resend and Brevo take an idempotency key built from the outbox id, so a
retry after a timeout does not send twice. Postmark has none: a retry after
a timeout it did accept sends the message twice (the outbox id is in its
metadata). `SKRONGLE_MAIL_FROM` must be a sender the provider has verified.

Amazon SES first, a second service as fallback:

```sh
SKRONGLE_MAIL_FROM=Skrongle <noreply@learn.example.org>
SKRONGLE_MAIL_PROVIDERS=ses,backup
SKRONGLE_MAIL_SES_KIND=smtp
SKRONGLE_MAIL_SES_HOST=email-smtp.eu-west-1.amazonaws.com
SKRONGLE_MAIL_SES_USER=AKIA…
SKRONGLE_MAIL_SES_PASS=…
SKRONGLE_MAIL_BACKUP_KIND=smtp
SKRONGLE_MAIL_BACKUP_HOST=smtp.postmarkapp.com
SKRONGLE_MAIL_BACKUP_USER=…
SKRONGLE_MAIL_BACKUP_PASS=…
```

Or Resend's API as the fallback after SES:

```sh
SKRONGLE_MAIL_PROVIDERS=ses,resend
SKRONGLE_MAIL_RESEND_KIND=resend
SKRONGLE_MAIL_RESEND_API_KEY=re_…
```

Or Postmark's HTTP API as the fallback:

```sh
SKRONGLE_MAIL_PROVIDERS=ses,postmark
SKRONGLE_MAIL_POSTMARK_KIND=postmark
SKRONGLE_MAIL_POSTMARK_API_KEY=…   # Server API token
```

Or Brevo's HTTP API as the fallback:

```sh
SKRONGLE_MAIL_PROVIDERS=ses,brevo
SKRONGLE_MAIL_BREVO_KIND=brevo
SKRONGLE_MAIL_BREVO_API_KEY=xkeysib-…
```

The sender's domain needs SPF and DKIM records for each provider (each
provider's console shows them), or mail lands in spam. A new SES account
only sends to verified addresses until you ask AWS to lift the sandbox.
The log names each message by id and says which provider took it.

## Text messages

Texts carry phone verification and two-factor sign-in codes. Texts work like e-mail: an outbox, a background sender, providers
tried in `SKRONGLE_SMS_PROVIDERS` order with the same fallback and retries.
Each account may queue 5 texts an hour and 20 a day, and each phone number
may receive 5 an hour and 10 a day: sign-in codes and other texts (a
number's verification code) apart, so verification texts never use up a
number's sign-in codes. The whole server sends at most
`SKRONGLE_SMS_MAX_PER_HOUR` and `SKRONGLE_SMS_MAX_PER_DAY` texts of each of those
two kinds, and only to the countries in `SKRONGLE_SMS_COUNTRIES`. The limits
count a ledger (`sms_sends`: the number's SHA-256, the account, when) that
stays when an account is deleted and is purged after two days, so deleting
and re-registering gives no allowance back. Only accounts with a confirmed
e-mail address or Google sign-in may add a phone number.

| Variable | Default | Meaning |
|---|---|---|
| `SKRONGLE_SMS_COUNTRIES` | `1,44,353,33,34,49,39,31,32,41,43,45,46,47,358,351,61,64` | Calling codes texts may go to, separated by commas (`+` optional). A longer prefix narrows one (`1415`: San Francisco only). `*` allows every country. The default is North America (+1, which includes some Caribbean ranges), the UK and Ireland, western and northern Europe, Australia and New Zealand; toll fraud mostly targets premium ranges elsewhere. |
| `SKRONGLE_SMS_MAX_PER_HOUR` | `100` | Texts the whole server may queue in an hour, for sign-in codes and for other texts each. |
| `SKRONGLE_SMS_MAX_PER_DAY` | `500` | The same per day. |

Each provider is named in `SKRONGLE_SMS_PROVIDERS` and configured with
`SKRONGLE_SMS_<NAME>_…`. `_KIND` is `http` (defaults to the name): one HTTP
POST per text, which fits most SMS APIs and any webhook.

| Variable | Default | Meaning |
|---|---|---|
| `SKRONGLE_SMS_<NAME>_URL` | — | Where to POST. Must be `https://` (`http://` only for this machine). |
| `SKRONGLE_SMS_<NAME>_FORMAT` | `json` | `json` or `form` (`application/x-www-form-urlencoded`). |
| `SKRONGLE_SMS_<NAME>_BODY` | see below | The body, with `{to}`, `{from}`, `{text}`, `{id}`, `{purpose}`; must contain `{to}` and `{text}`. In JSON put each placeholder inside quotes. |
| `SKRONGLE_SMS_<NAME>_AUTH` | — | An auth header's value, e.g. `Bearer abc…`. |
| `SKRONGLE_SMS_<NAME>_AUTH_HEADER` | `Authorization` | The header `_AUTH` goes in, e.g. `X-API-Key`. |
| `SKRONGLE_SMS_<NAME>_USER`, `_PASS` | — | HTTP Basic credentials instead of `_AUTH`; both or neither. |
| `SKRONGLE_SMS_<NAME>_FROM` | `SKRONGLE_SMS_FROM` | The sender number or ID, `{from}`. |

The default bodies are `{"to":"{to}","from":"{from}","text":"{text}","id":"{id}","purpose":"{purpose}"}`
for `json` and `To={to}&From={from}&Body={text}` for `form`. A 2xx answer
means sent; 5xx, 408, 429 or no answer means try later (and the next
provider now); any other 4xx means this provider refuses the text.

Twilio first, an own webhook as fallback:

```sh
SKRONGLE_SMS_FROM=+15017122661
SKRONGLE_SMS_PROVIDERS=twilio,hook
SKRONGLE_SMS_TWILIO_KIND=http
SKRONGLE_SMS_TWILIO_URL=https://api.twilio.com/2010-04-01/Accounts/AC…/Messages.json
SKRONGLE_SMS_TWILIO_FORMAT=form
SKRONGLE_SMS_TWILIO_USER=AC…
SKRONGLE_SMS_TWILIO_PASS=…            # the auth token
SKRONGLE_SMS_HOOK_KIND=http
SKRONGLE_SMS_HOOK_URL=https://hooks.example.org/sms
SKRONGLE_SMS_HOOK_AUTH=Bearer …
```

Numbers are international (`+` and the country code). The log names each
text by id and its number's last two digits, and says which provider took it.

## Without e-mail or text messages

Neither is required. Leave `SKRONGLE_MAIL_PROVIDERS` and `SKRONGLE_SMS_PROVIDERS`
unset and the app works without them (#633): the log says so once at start
(`e-mail: not configured; features that need it are off
(SKRONGLE_MAIL_PROVIDERS)`, and the same for texts), and every feature that
would send a message hides itself instead of sending into the log.

| Feature | Without e-mail | Without texts |
|---|---|---|
| Confirming an address | No link is sent. The e-mail field shows an (i), not the red !, and nothing asks to send it again. | — |
| Phone number | — | The profile has no phone field. |
| Two-factor sign-in | The e-mail method is not offered. | The text message method is not offered. |
| Sharing a course | After inviting, the Share dialog shows each address's link with a Copy button, once (links are stored only hashed); pass it on yourself. | — |
| Download my data | No mail: when the copy is ready, the Data section's **Download my data** button gets it (the button is there with mail too). | — |

The authenticator app needs neither, so two-factor sign-in still works. An
account whose second step was e-mail or texts, on a server that has since
lost that provider, cannot sign in: the login page says so and names the
setting. Set the provider again, or turn two-factor sign-in off for that
account in the database (`UPDATE users SET two_factor = '' WHERE email = …`).

In dev mode the dev mailbox and dev phone count as providers, so nothing
is hidden there; `SKRONGLE_DEV_SMTP=0` turns the mailbox off to see the
server without e-mail.

## Accounts and AI keys

`SKRONGLE_SIGNUP` decides who may make an account. The systemd unit sets it
to `invite`:

1. Start the server. While it has no account, it writes a one-time
   **owner link** to its log. Print it:

   ```sh
   sudo journalctl -u skrongle -g "Owner link" -o cat | tail -n 1
   ```

   Open that link and register: the account is yours, the owner's. It is
   a superuser, so its account menu (the picture, top right) has a
   **Superuser** page. Without the
   link, `/register` only says the server has no owner yet, so someone
   else on your network can't make the first account before you. Every
   restart makes a new link (the old one stops working) until the owner's
   account exists. The link starts with `SKRONGLE_PUBLIC_URL` when set, else
   `http://<hostname>.local:<port>`; if that address doesn't open, keep the
   part from `/register` and put the address you use in front.
2. For everyone else, open **Superuser** and click
   **Make an invitation link**. Copy the link (it is shown only then) and
   send it to one person. It works once, for 7 days; **Revoke** takes back
   one nobody has used yet.
3. The person opens the link and registers, with a password or "Sign up
   with Google". Without a link, `/register` only says that sign-ups are
   closed and to ask whoever runs the server.

The same page lists the open invitations and the accounts (e-mail address,
when it was made, last sign-in; 25 to a page). The other values:

| `SKRONGLE_SIGNUP` | Who may register |
|---|---|
| `open` (unset) | Anyone who can reach the server. The hosted site runs this way. |
| `invite` | The first account (the owner, a superuser, through the owner link in the log), then only through an invitation link. |
| `closed` | The first account (the owner, a superuser, through the owner link in the log), then nobody. |

Any other value stops the server at
startup with a message naming it; the log says which mode is on. Existing
accounts always sign in, Google included. A server that already has
accounts when you turn `invite` on gets no new owner: make one a superuser
with `make-owner` (below).

Someone forgot their password, or lost the phone their two-factor codes go
to? On the **Superuser** page, each account has **Temporary
password** (a new password, shown to you once, that they must change at
their next sign-in) and **Turn off two-factor**. No message is sent: give
them the password yourself.

### If you lock yourself out

The binary has commands that change the database directly, for when you
can't sign in yourself. They work with the server running or stopped. Run
them as root, with the database the server uses:

```sh
sudo SKRONGLE_DB=/var/lib/skrongle/learn.db /opt/skrongle/skrongle reset-password you@example.org
sudo SKRONGLE_DB=/var/lib/skrongle/learn.db /opt/skrongle/skrongle disable-2fa you@example.org
sudo SKRONGLE_DB=/var/lib/skrongle/learn.db /opt/skrongle/skrongle make-owner you@example.org
```

| Command | What it does |
|---|---|
| `reset-password <email>` | Prints a temporary password and signs the account out everywhere. Sign in with it; the app then asks for a new one before anything else. |
| `disable-2fa <email>` | Turns two-factor sign-in off: the password alone signs in. Turn it on again in the profile. |
| `make-owner <email>` | Makes the account a superuser: its account menu gets the **Superuser** page. |
| `help` | Lists the commands. |

An address no account has changes nothing and says so. Any other first
argument prints the list and exits with status 2. Built from the repo, the
binary is `target/release/spanish-learning` (`./spanish-learning
reset-password …`); a release names it `skrongle`.

Keep the server on your own network or behind Tailscale (below) anyway.
The AI features (tutor, course generator) use a key each learner saves
under *Profile settings → AI API keys* (Anthropic, OpenAI or Google). The
secret key encrypts saved keys, so a database without its `.key` file can't
use them.

Your server has no plans or payments: `skrongle.service` sets
`SKRONGLE_PLANS=off`, so every account has every feature and the profile
shows no subscription. To let everyone use the tutor and the course
generator without a key of their own, set one key for the whole server in
`/etc/skrongle/env`:

```sh
SKRONGLE_AI_KEY=sk-ant-…          # shared by every account on this server
SKRONGLE_AI_DAILY_CAP=2.00        # dollars a day across everyone (default 10.00)
SKRONGLE_AI_RATE_PER_MINUTE=12    # requests one account may start a minute
```

Everyone shares a server key. There is no monthly allowance per account,
you pay the provider's bill, and the daily cap is the only brake. Past it,
AI on the server's key stops until midnight UTC and the log says `ALERT`;
learners' own keys keep working. Every call is recorded in the
`ai_usage` table of the database.

## Reaching it from outside

Don't forward a port on the router. Install [Tailscale](https://tailscale.com)
on the server (`curl -fsSL https://tailscale.com/install.sh | sh`, then
`sudo tailscale up`) and on each phone or laptop; the server is then at
`http://<hostname>:8080` from any of them.

For a public site, put a TLS proxy (nginx, Caddy) in front, set
`SKRONGLE_BIND=127.0.0.1:8080`, `SKRONGLE_SECURE_COOKIES=1` and `SKRONGLE_PUBLIC_URL`.
`deploy/nginx-spanish.conf` is a working nginx example.

Anything that faces the internet should sit behind such a proxy, even
without HTTPS. The app has only coarse protection. It closes a connection
that takes more than 15 seconds to send its headers or stalls 30 seconds in
its body, takes at most `SKRONGLE_MAX_CONNECTIONS` connections, and refuses
bodies over 64 KB outside the course editor. A proxy buffers slow clients
and can limit them per address.

### Signing in at the proxy

If a proxy in front already knows who people are (Authelia, Authentik,
Tailscale, Cloudflare Access), Skrongle can take its word and skip its own
password form. Set both variables:

| Variable | Meaning |
|---|---|
| `SKRONGLE_PROXY_AUTH_HEADER` | The header the proxy puts the person's **e-mail address** in, e.g. `Remote-Email` or `Tailscale-User-Login`. |
| `SKRONGLE_PROXY_AUTH_FROM` | The proxy's own addresses, comma-separated, each an address or a range: `127.0.0.1`, `10.0.0.5, fd00::/8`. |

One without the other stops the server at startup. The log says which
header and addresses are trusted.

What happens then:

- A request that comes **straight from one of those addresses** (the
  connection itself, not `X-Forwarded-For`) and carries the header is
  signed in as the account with that e-mail address (any capitals). There
  is no two-factor step: the proxy did that. If the browser was signed in
  as someone else, that session ends.
- No account with that address: `SKRONGLE_SIGNUP` decides. `open` makes one
  with no password, its address already confirmed; it accepts the Terms on
  its first page. `invite` and `closed` show the "sign-ups are closed" page
  (on an empty server the first one is still let in, as the owner).
  Invitation links don't help here, since `/register` has no form. When the
  proxy only lets your own people through, set `SKRONGLE_SIGNUP=open`: the
  proxy already decides who gets in.
- `/login` and `/register` only say that the proxy signs you in. `/logout`
  ends the session and says the next page through the proxy signs you in
  again.
- The header from **any other address** is ignored and logged (once an hour
  per address). So the proxy must be the only way in: bind Skrongle to
  `127.0.0.1` (or firewall it) so nobody reaches it around the proxy, and
  make the proxy **overwrite** the header, never pass on one the browser
  sent.

**Run the proxy on the same machine, or reach Skrongle over a VPN.** The
address check proves only which machine a connection came from. If the
proxy and Skrongle talk over a plain network such as home Wi-Fi, a LAN or
the internet, anyone on that network can pretend to be the proxy and sign in
as any account. Put the proxy on the Skrongle machine and connect it to
`127.0.0.1`, where the traffic never leaves the machine. If the proxy runs
elsewhere, connect it to Skrongle over an encrypted VPN such as Tailscale or
WireGuard, and set `SKRONGLE_PROXY_AUTH_FROM` to the proxy's VPN address. We
don't recommend any other setup.

**Authelia with nginx** (`auth_request`). Authelia's `Remote-User` is a user
name; use `Remote-Email` (or `Remote-User` only if your user names are
e-mail addresses). `SKRONGLE_BIND=127.0.0.1:8080`,
`SKRONGLE_PROXY_AUTH_HEADER=Remote-Email`, `SKRONGLE_PROXY_AUTH_FROM=127.0.0.1`:

```nginx
location /internal/authelia/authz {
    internal;
    proxy_pass http://127.0.0.1:9091/api/authz/auth-request;
    proxy_set_header X-Original-Method $request_method;
    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header Content-Length "";
    proxy_pass_request_body off;
}

location / {
    auth_request /internal/authelia/authz;
    auth_request_set $email $upstream_http_remote_email;
    auth_request_set $redirection_url $upstream_http_location;
    error_page 401 =302 $redirection_url;
    # Always set: replaces any Remote-Email the browser sent.
    proxy_set_header Remote-Email $email;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_pass http://127.0.0.1:8080;
}
```

**Tailscale Serve.** Serve puts the signed-in tailnet user's login in
`Tailscale-User-Login` (and drops one the browser sent). It is an e-mail
address for most identity providers; a GitHub login looks like
`name@github`, which is not one, and gets no account. With
`SKRONGLE_BIND=127.0.0.1:8080`, `SKRONGLE_PROXY_AUTH_HEADER=Tailscale-User-Login`
and `SKRONGLE_PROXY_AUTH_FROM=127.0.0.1`:

```sh
sudo tailscale serve --bg 8080
```

The server is then at `https://<hostname>.<tailnet>.ts.net`, and everyone on
the tailnet is signed in as themselves. Requests from tagged devices carry
no user and get the usual "the proxy signs you in" page. Trusting
`127.0.0.1` trusts every program on the server too; that is fine on a box
that only runs Skrongle.

A client that keeps no cookies (a script, `curl`) gets a new session on every
request; sessions expire after 30 days and are cleaned up hourly.

## Updating

One line:

```sh
sudo /opt/skrongle/update.sh
```

It finds the latest release, downloads the tarball for this machine and
checks its checksum, stops the service, copies the database aside as
`learn.db.before-<version>`, replaces the binary and `data/`, and starts the
service again. Give it a version to install that one instead
(`sudo /opt/skrongle/update.sh 0.2.0`), which is also how to go back.
Database migrations run when the new server starts. If the release notes
say the unit changed, copy `skrongle.service` to `/etc/systemd/system/`
again and run `sudo systemctl daemon-reload` (the script tells you when the
two differ).

By hand, the same thing:

```sh
V=0.2.0
curl -LO https://github.com/alexbeeken/pooki/releases/download/v$V/skrongle-$V-$(uname -m).tar.gz
curl -LO https://github.com/alexbeeken/pooki/releases/download/v$V/skrongle-$V-$(uname -m).tar.gz.sha256
sha256sum -c skrongle-$V-$(uname -m).tar.gz.sha256
sudo systemctl stop skrongle
sudo rm -rf /opt/skrongle/data
sudo tar -xzf skrongle-$V-$(uname -m).tar.gz -C /opt/skrongle
sudo systemctl start skrongle
```

## Backup and restore

The whole state is two files in `/var/lib/skrongle`:

| File | What it is |
|---|---|
| `learn.db` | The SQLite database: accounts, progress, courses made in the app. (`learn.db-wal` and `learn.db-shm` next to it belong to it while the server runs.) |
| `learn.db.key` | The secret key that encrypts the AI keys learners saved. Without it those keys can't be read. If you set `SKRONGLE_SECRET_KEY` instead, keep a copy of that value. |

(`learn.db.exports/` beside them holds "Download my data" files for a day at
most; it need not be backed up.) A deleted account stays in your backups
and in the journal until they expire. The hosted site promises its users
31 days at most; on your server that limit is yours to set and keep. Keep the two together, and somewhere other
than the SD card. Stop the service while copying so the database is
consistent. The backup holds every account and the key that decrypts stored
API keys, so it is created readable by you only (`umask 077`; an old copy is
removed first, since umask only applies to new files):

```sh
sudo systemctl stop skrongle
(umask 077 && rm -f skrongle-backup.tar.gz && sudo tar -czf - -C /var/lib/skrongle . > skrongle-backup.tar.gz)
sudo systemctl start skrongle
```

To back up without stopping, let SQLite write a consistent copy (needs the
`sqlite3` package), then add the key:

```sh
sudo sqlite3 /var/lib/skrongle/learn.db ".backup /root/learn.db"
sudo cp /var/lib/skrongle/learn.db.key /root/
```

To restore, on the same machine or a fresh install:

```sh
sudo systemctl stop skrongle
sudo rm -f /var/lib/skrongle/learn.db-wal /var/lib/skrongle/learn.db-shm
sudo tar -xzf skrongle-backup-2026-10-17.tar.gz -C /var/lib/skrongle
sudo systemctl start skrongle
```

(`/var/lib/skrongle` belongs to the user systemd creates for the service;
systemd fixes the ownership of the restored files when it starts it.)

If the database has saved AI keys but its key is missing, or the key there
cannot read them (a `SKRONGLE_SECRET_KEY` from another install, a
`learn.db.key` from a different backup), the server won't start and its log
says so; `skrongle doctor` shows it as a PROBLEM on the `secret key` line.
Restore `learn.db.key` from the same backup as the database. If the key is
lost for good, add `SKRONGLE_NEW_SECRET_KEY=1` to
`/etc/skrongle/env`, restart once, then remove the line: a new key is made,
the saved AI keys are deleted and learners enter theirs again. An
authenticator app set up for two-factor sign-in was sealed with the old key
too: it is turned off, and those learners sign in with their password and
set it up again. Everything else is unaffected.

## Releases

For maintainers. A release is a tag that matches the `version` in
`Cargo.toml` (bump it, merge, then tag main; the workflow refuses a tag
that doesn't match). Pushing `v<version>` runs
`.github/workflows/release.yml`, which builds both architectures with
`scripts/build-pi.sh` (cargo-zigbuild, glibc 2.31), packs each with
`scripts/package-release.sh` and attaches the tarballs and checksums to the
GitHub release of that tag (created with generated notes if it doesn't
exist yet). The binary reports the tag's version (`skrongle --version`).

```sh
git tag v0.2.0 origin/main && git push origin v0.2.0
```

*Actions → release → Run workflow* builds the same tarballs as workflow
artifacts without making a release. To check the layout locally:
`scripts/build-pi.sh aarch64 && scripts/package-release.sh 0.0.0 aarch64`.

## Troubleshooting

| Symptom | Check |
|---|---|
| `ssh: Could not resolve hostname skrongle.local` | The Pi isn't on the Wi-Fi yet (wait a few minutes after the first boot), or the Wi-Fi details in Imager were wrong. Flash the card again. |
| The page doesn't load from a phone | Use the IP address instead of `.local`; make sure the phone is on the same Wi-Fi; `systemctl status skrongle`. |
| `port 8080 is already in use` in the log | Something else uses port 8080: set `SKRONGLE_BIND=0.0.0.0:8081` in `/etc/skrongle/env`. |
| `cannot write the database` in the log | The folder isn't writable by the service: with the unit above it is `/var/lib/skrongle`; if you changed `SKRONGLE_DB`, the new folder must belong to the service user. |
| `the secret key is missing` in the log | See [Backup and restore](#backup-and-restore): restore `learn.db.key`. |
| Service keeps restarting | `journalctl -u skrongle -n 50`. A course validation error names the file and field. |
| Locked out of your own account | [If you lock yourself out](#if-you-lock-yourself-out): `reset-password`, `disable-2fa`. |
| Anything else | Run [`skrongle doctor`](#diagnostics-skrongle-doctor) and read the [FAQ](self-hosting-faq). |
