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).

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:

FileFor
skrongle-<version>-aarch64.tar.gz64-bit ARM: Raspberry Pi Zero 2 W, 3, 4, 5 on the 64-bit OS
skrongle-<version>-x86_64.tar.gzPCs, mini PCs, most VPSs

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

PathWhat it is
skrongleThe 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.serviceThe systemd unit below (deploy/skrongle.service in the repo).
terms.example.mdA template for your own Terms of Service (deploy/terms.example.md); see Your own Terms.
update.shUpdates the install in place: see Updating.
VERSIONThe 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/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);
  • 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.

VariableDefaultMeaning
SKRONGLE_PLANSon (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_BIND0.0.0.0:8080Address and port to listen on; a bare port (8081) means every interface.
SKRONGLE_SIGNUPopen (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_DIRdataFolder with courses/ and themes/.
SKRONGLE_DBlearn.dbSQLite 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_KEYoff1 only when the secret key is lost for good: see Backup and restore.
SKRONGLE_OPENAI_BASE_URLhttps://api.openai.comPoint the OpenAI provider at any OpenAI-compatible server, e.g. Ollama on another machine.
SKRONGLE_ALLOW_PRIVATE_AI_URLSoff1 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_SECS3600How long one AI generation job may run in all. Raise it for a slow self-hosted model.
SKRONGLE_MAX_CONNECTIONS256Open connections the server allows; more clients wait until one closes. Keep it well under the unit’s LimitNOFILE (1024).
SKRONGLE_LOGinfoerror, warn, info, debug or trace.
SKRONGLE_SECURE_COOKIESoff1 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_TERMSbuiltin (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_AGE14The minimum age the registration checkbox states.
SKRONGLE_FAILED_COURSE_RETENTION_DAYS30An 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_USER50 (no limit in dev mode)Courses one account may make. “Create a course” says so when an account has them all.
SKRONGLE_COURSE_BYTES8388608 (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_USER33554432 (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_HOUR3000The 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_WINDOW3600The 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_DAYsee Text messagesWhere 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).

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).

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.

VariableDefaultMeaning
SKRONGLE_MAIL_<NAME>_HOST—SMTP server.
SKRONGLE_MAIL_<NAME>_TLSstarttlsstarttls (port 587), tls (port 465), or none for a server on this machine only.
SKRONGLE_MAIL_<NAME>_PORTfrom _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.

VariableDefaultMeaning
SKRONGLE_MAIL_<NAME>_KINDthe nameresend: 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_URLthe 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>_STREAMoutboundpostmark 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.

VariableDefaultMeaning
SKRONGLE_SMS_COUNTRIES1,44,353,33,34,49,39,31,32,41,43,45,46,47,358,351,61,64Calling 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_HOUR100Texts the whole server may queue in an hour, for sign-in codes and for other texts each.
SKRONGLE_SMS_MAX_PER_DAY500The 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.

VariableDefaultMeaning
SKRONGLE_SMS_<NAME>_URL—Where to POST. Must be https:// (http:// only for this machine).
SKRONGLE_SMS_<NAME>_FORMATjsonjson or form (application/x-www-form-urlencoded).
SKRONGLE_SMS_<NAME>_BODYsee belowThe 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_HEADERAuthorizationThe 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>_FROMSKRONGLE_SMS_FROMThe 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.

FeatureWithout e-mailWithout texts
Confirming an addressNo 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-inThe e-mail method is not offered.The text message method is not offered.
Sharing a courseAfter 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 dataNo 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:

    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_SIGNUPWho may register
open (unset)Anyone who can reach the server. The hosted site runs this way.
inviteThe first account (the owner, a superuser, through the owner link in the log), then only through an invitation link.
closedThe 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
CommandWhat 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.
helpLists 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:

VariableMeaning
SKRONGLE_PROXY_AUTH_HEADERThe header the proxy puts the person’s e-mail address in, e.g. Remote-Email or Tailscale-User-Login.
SKRONGLE_PROXY_AUTH_FROMThe 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:

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:

FileWhat it is
learn.dbThe 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.keyThe 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

SymptomCheck
ssh: Could not resolve hostname skrongle.localThe 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 phoneUse 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 logSomething else uses port 8080: set SKRONGLE_BIND=0.0.0.0:8081 in /etc/skrongle/env.
cannot write the database in the logThe 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 logSee Backup and restore: restore learn.db.key.
Service keeps restartingjournalctl -u skrongle -n 50. A course validation error names the file and field.
Locked out of your own accountIf you lock yourself out: reset-password, disable-2fa.
Anything elseRun skrongle doctor and read the FAQ.