Self-hosting
The technical version of the Host it yourself 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 answers the first questions people ask.
Self-hosting is free. Help is best-effort, in GitHub 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).
- 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). - Your own Terms.
SKRONGLE_TERMS=/etc/skrongle/terms.md(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). 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).
- Already run a sign-in proxy (Authelia, Tailscale Serve)? Let it sign people in: Signing in at the proxy.
- Locked yourself out? The binary resets a password or two-factor from the shell: If you lock yourself out.
What a release contains
Each GitHub release 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. |
update.sh | Updates the install in place: see 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
(the commands below use 0.2.0; uname -m picks the architecture):
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:
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/skrongleas a user systemd creates for it (DynamicUser=yes), so there is nouseraddstep; - 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); - lets people sign up only with an invitation from you
(
SKRONGLE_SIGNUP=invite, see Accounts and AI keys); - turns plans and payments off (
SKRONGLE_PLANS=off, see Accounts and AI keys); - reads optional settings from
/etc/skrongle/env.
Useful commands:
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 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. 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. |
SKRONGLE_PROXY_AUTH_HEADER, SKRONGLE_PROXY_AUTH_FROM | — | Let the proxy in front sign people in; see 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. |
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. |
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. 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. |
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. Unset, texts are only logged. |
SKRONGLE_SMS_COUNTRIES, SKRONGLE_SMS_MAX_PER_HOUR, SKRONGLE_SMS_MAX_PER_DAY | see Text messages | Where texts may go and how many the server sends. |
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:
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:
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).
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).
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’s API. postmark: Postmark’s API. brevo: Brevo’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:
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:
SKRONGLE_MAIL_PROVIDERS=ses,resend
SKRONGLE_MAIL_RESEND_KIND=resend
SKRONGLE_MAIL_RESEND_API_KEY=re_…
Or Postmark’s HTTP API as the fallback:
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:
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:
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:
-
Start the server. While it has no account, it writes a one-time owner link to its log. Print it:
sudo journalctl -u skrongle -g "Owner link" -o cat | tail -n 1Open 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,
/registeronly 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 withSKRONGLE_PUBLIC_URLwhen set, elsehttp://<hostname>.local:<port>; if that address doesn’t open, keep the part from/registerand put the address you use in front. -
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.
-
The person opens the link and registers, with a password or “Sign up with Google”. Without a link,
/registeronly 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:
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:
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
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_SIGNUPdecides.openmakes one with no password, its address already confirmed; it accepts the Terms on its first page.inviteandclosedshow 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/registerhas no form. When the proxy only lets your own people through, setSKRONGLE_SIGNUP=open: the proxy already decides who gets in. /loginand/registeronly say that the proxy signs you in./logoutends 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:
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:
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:
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:
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):
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:
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:
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).
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: 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: reset-password, disable-2fa. |
| Anything else | Run skrongle doctor and read the FAQ. |