Three ways to run this, in order of how little you have to know:
| Cost | You need | |
|---|---|---|
| Render | ~$10/month | A browser and a GitHub account |
| Docker | Your server | Somewhere that runs containers |
| Bare Node | Your server | Node 22.6+, and a reverse proxy for HTTPS |
Whichever you choose, read Backups and Email before you let real people use it. A compliance system nobody can restore is not a compliance system.
The whole thing is a browser exercise. No terminal beyond two copy-paste commands.
1. Fork or push this repository to GitHub.
2. Create the service. At render.com, New → Blueprint, pick the
repository. It reads render.yaml and proposes a web service with a 10 GB disk attached. Accept it.
Render asks for the values marked sync: false. You can leave the email and backup ones blank for
now and add them later; the app runs without them.
3. Create your first login. When the deploy goes green, open the service's Shell tab:
node scripts/create-admin.mjs "Your Organization" "Your Name" you@example.orgIt prints a generated password. The first account created is also the platform owner, so it can create further organizations if you run training for more than one.
Use this rather than
npm run seed, which creates a demo organization full of fictional staff.
4. Check the disk is real. Render → Manual Deploy → Clear build cache & deploy. When it returns, your data should still be there. Five minutes now saves a bad surprise in six months.
5. Add your domain. Service → Settings → Custom Domains, add yours; Render shows a target value. At your DNS provider, point that hostname at the target with a CNAME.
If a record for that name already exists, delete it — a CNAME is only valid when it is the only record at that name, and leaving an old A record in place means resolvers keep answering with the old address.
Then set APP_URL in Render to https://your-hostname, since that is what links in emails use.
git clone <your fork>
cd simple-lms
printf 'SESSION_SECRET=%s\n' "$(openssl rand -base64 48)" > .env
docker compose up -d --build
docker exec -it simple-lms node scripts/create-admin.mjs "Your Organization" "Your Name" you@example.orgThe container listens on 3080 and keeps everything in /data, which the compose file maps to a
directory on the host. Put a reverse proxy in front for HTTPS.
Two proxy settings matter regardless of which one you use:
client_max_body_size 512Mor equivalent. SCORM packages routinely run to 50–200 MB, and the default 1 MB limit rejects them with a confusing error.- Generous timeouts —
proxy_read_timeout 600s. Large uploads take longer than 60 seconds.
Your proxy must pass X-Forwarded-Proto. The session cookie is marked Secure based on it; without
it the app assumes plain HTTP and browsers will discard the cookie over HTTPS, which looks exactly
like a broken login.
npm install
npm run build
LMS_DATA_DIR=/var/lib/simple-lms SESSION_SECRET="$(openssl rand -base64 48)" npm startServes on port 3000. Same proxy notes as above.
| Variable | Needed | What it does |
|---|---|---|
SESSION_SECRET |
Yes | Signs session cookies. Any long random string. Changing it signs everyone out. |
LMS_DATA_DIR |
Where the database and packages live. Defaults to ./data. |
|
APP_URL |
For email | The public URL, used for links inside emails. |
RESEND_API_KEY |
For email | From resend.com. |
MAIL_FROM |
For email | Training <training@yourdomain>. Must be on a domain the key may send from. |
MAIL_REPLY_TO |
A mailbox someone reads. Without it, replies go nowhere. | |
NOTIFY_HOUR_UTC |
Hour of the daily reminder pass. Default 13. | |
B2_KEY_ID B2_APP_KEY B2_BUCKET |
For backups | Backblaze B2 credentials. |
BACKUP_HOUR_UTC BACKUP_RETAIN_DAYS |
Default 07:00 UTC, 30 days. | |
COOKIE_SECURE |
Force the cookie's Secure flag on or off. Detected automatically otherwise. | |
DEBUG_TOKEN |
Enables the diagnostic endpoints. Leave unset normally. |
Without RESEND_API_KEY and MAIL_FROM nothing is delivered. Reminders that fall due are recorded
as suppressed rather than skipped, so switching email on later sends what is due next instead of
weeks of backlog in one go.
- Verify a sending domain at resend.com and add the DNS records it gives you.
A dedicated subdomain such as
notifications.yourdomainis preferable: it keeps this mail's reputation separate from anything you send by hand. - Create an API key with Sending access, restricted to that domain.
- Set
RESEND_API_KEY,MAIL_FROM,APP_URLandMAIL_REPLY_TO.
MAIL_FROM must be on the domain the key is authorised for, or every send is rejected.
Then open Email in the admin navigation and press Send me a test. The page lists every message with its result and the provider's message id, so a wrong key or unverified domain shows up immediately rather than silently.
Watch the sending limits. Resend's free plan allows 3,000 messages a month but only 100 a
day, which is the one people hit. Adding N people generates roughly 2N messages — an invitation
and an assignment notice each. A pass stops at 100 and picks up the rest next time rather than
generating failures; raise MAIL_MAX_PER_RUN on a paid plan.
With the three B2_* variables set, the app backs itself up nightly: a consistent SQLite snapshot
plus every uploaded package, thirty days of history. Backblaze's first 10 GB is free, which for most
installations means backups cost nothing.
npm run backup # run one now
npm run backup:list # what is stored
npm run restore # list, then: node scripts/restore.mjs --latestRestores move your current data aside rather than overwriting, so they are reversible too.
Run a restore before you need one. Everything the app owns is the data directory — lms.db and
scorm/ — so also make sure a copy leaves the building. Compliance records that exist only on one
machine are one flood away from not existing, which is the scenario the auditor is asking about.
Updating. Pull and rebuild. The schema migrates itself on startup; your data directory is untouched.
Forgotten administrator password.
npm run accounts # who has an account
npm run reset-password -- you@example.orgThe in-app reset needs an administrator already signed in, so this is the way back when the only admin is locked out. Console resets are written to the activity log.
When sign-in misbehaves. Set DEBUG_TOKEN, then visit /api/session-debug?token=… in the
browser where it fails. It reports what the server sees — protocol header, whether a cookie arrived,
whether its signature matches, recent sign-ins, the running commit — and names the cause. Unset the
variable afterwards.
/api/health needs no token and reports the running commit, so you can confirm a deploy took.
| Symptom | Cause |
|---|---|
| 413 or a stalled course upload | Proxy body-size limit. See the Docker section. |
| Course loads but records nothing | It is a SCORM 2004 package. Check <schemaversion> in imsmanifest.xml; ask your vendor for 1.2. |
| Signed in, then the next click returns to the login page | The cookie is being marked Secure over a plain-HTTP connection, or your proxy is not sending X-Forwarded-Proto. |
/api/health returns 503 |
The data directory is not writable. |
| Everyone signed out after a restart | SESSION_SECRET changed or was never set. |
| Reminders show as suppressed | RESEND_API_KEY or MAIL_FROM is missing. |
| Reminders show as failed | The Email page shows the provider's own error — usually a MAIL_FROM outside the key's domain. |