Architecture
How the codebase is put together: stack, boot sequence, and how a request moves through the app. This page is for contributors and self-hosters who want to understand the internals — for what the app does, see the Overview and Using the App; for the data itself, see Data Model.
Stack
- Runtime: Node.js, Express 5.
- Database: MongoDB via Mongoose 9. One connection, no read replicas or sharding logic.
- Views: server-rendered EJS templates — no client-side framework or virtual DOM. Interactivity is plain JavaScript per page (see Frontend).
- Sessions:
express-sessionwith the default in-memory store (no Redis/Mongo session store). - Auth: local (bcrypt) or LDAP/Active Directory (Passport's
passport-ldapauthstrategy). No OIDC/SAML/SSO exists in this codebase. - CSS: Sass, compiled to a single
public/css/main.cssat build time — no CSS framework. - Uploads: Multer, disk storage for exercise media, memory storage for CSV files and TLS certificates.
- Scheduled jobs:
node-cron, for license revalidation. - Logging: Winston, fed by Morgan's HTTP access logs.
There's no bundler or transpiler — npm run build-css only compiles Sass; JavaScript is loaded as
plain <script> tags per view.
Boot sequence (server.js)
- Load environment (
dotenv), readconfig/config.jsforwebFQDN,webPort,authMode. - Connect to MongoDB (
config/mongoose.js). On success, start the license scheduler (services/licensing/scheduler.js) — this runs unconditionally, even before an admin account exists, because the license gate itself runs before any route. - If
authMode === 'ldap', register the Passport LDAP strategy (config/ldap.js). Passport is always initialized (passport.initialize()), but it's session-less —express-sessionis the actual session mechanism, not Passport sessions. - Configure the EJS view engine, static file serving (
public/), body parsing (express.urlencoded,express.json), andmethodOverride('_method')so HTML forms can sendPUT/DELETE. - Wire Morgan access logs into the Winston logger.
- Mount session middleware, then a small locals-injector that sets
res.locals.user,res.locals.path, andres.locals.buildInfoon every request (so views never have to ask for them explicitly). - Mount the license gate (
middleware/accessGate.js) — before every route, including login. - Mount forced-password-change enforcement (
middleware/forcePasswordChange.js). - Mount routers (see Request flow below).
- 404 handler, then a generic error handler that renders the
errorview. - Start listening — HTTP or HTTPS depending on whether a valid TLS cert/key pair is on file (see
Settings storage). The server can be restarted in place (new hostname or TLS
settings applied without a container restart) via an exported
restartServerfunction used by the admin settings screen.
Middleware pipeline
In order, for every request:
static files → body parsers → morgan/logging → session → locals injector
→ license gate (accessGate.js)
→ forced-password-change (forcePasswordChange.js)
→ router (auth / plans / exercises / admin / license / users)
→ controller action
Two middleware modules gate everything below them, including pages that would otherwise need no auth at all:
- License gate (
middleware/accessGate.js→middleware/license.js'srequireLicense) — allowlists only/licenseand/api/license; every other route, including the login page, is blocked until the cached license is valid. See Licensing System. - Forced password change (
middleware/forcePasswordChange.js) — if the logged-in user'smustChangePasswordflag is set, every route except/auth/change-passwordand/auth/logoutis blocked (JSON 403 for/api/*, redirect otherwise) until they change it. See Auth & Sessions.
One smaller middleware module applies only where routes opt in: middleware/auth.js —
requireAuth (must be logged in) and requireAdmin (must be an admin), applied per-route in
routes/*.js.
Request flow
Requests are routed by top-level mount point in server.js, each pairing a routes/*.js file with
one or more controllers/*.js files:
| Mount | Routes file | Controller(s) | Covers |
|---|---|---|---|
/auth | routes/auth.js | authController.js | Login (local/LDAP), logout, change password |
/exercises | routes/exercises.js | exercisesController.js | Exercise library CRUD, history chart data |
/plans | routes/plans.js | plansController.js | Plan CRUD, execution, subscriptions, CSV export/import of your own plans |
/admin | routes/admin.js | adminController.js, settingsController.js, importExportController.js | Users, exercises, sessions, settings, full-instance CSV, license (admin view) |
/license | routes/licensePages.js | licenseController.js | Activation/trial/checkout pages |
/api/license | routes/api/license.js | licenseController.js | Unauthenticated trial/activate JSON endpoints (must work with no session, since an unlicensed instance has no working app behind the gate) |
/ (root) | routes/users.js | usersController.js | Profile, appearance, own-data CSV backup |
GET / | — | homeController.js | Dashboard |
A typical read/write request looks like:
Browser → Express router (routes/*.js, with per-route requireAuth/requireAdmin)
→ controller action (controllers/*.js)
→ service/database helper (services/database/*, services/csv/*, etc.) or Mongoose model directly
→ MongoDB
→ controller renders an EJS view (res.render) or returns JSON (res.json), depending on the route
Simple CRUD controllers (exercises, plans, users, admin) talk to Mongoose models directly or through
thin services/database/* helpers that centralize a query used in more than one place (e.g.
getExercisePlanUsage(), which scans every plan for exercise references and is used by both the admin
exercise list and the delete-safety check). Heavier cross-cutting concerns — licensing, CSV
import/export, TLS/settings — live in their own services/ subdirectories rather than in controllers.
See Data Model, Licensing System, and
CSV Import & Export for those.
Settings storage
Most configuration is environment variables, read once at boot (config/config.js,
config/mongoUri.js, config/ldap.js, config/licensing.js). Two things are deliberately not
environment variables, because they need to be editable from the admin UI at runtime without a
restart:
services/settings/store.js— a JSON file (.secrets/settings.json) holdingwebFQDNand TLS enable/disable. It's a flat file rather than a database collection specifically so it can be read before MongoDB is even connected, and so the app never depends on the database being reachable just to know its own hostname.services/settings/tlsCerts.js— uploaded certificate/key PEM files, stored at.secrets/tls/{cert,key}.pem. Validated with Node's built-inX509Certificateandtls.createSecureContextbefore being accepted (rejects a malformed or mismatched pair with a human-readable error rather than crashing the server later). If no valid pair is on file, the app falls back to plain HTTP rather than refusing to start.
Both live under the same .secrets/ volume as the session secret and bootstrap admin credentials, so
they survive container recreation.
Deployment notes
See the Overview for the self-hosted setup steps. A few architecturally relevant points worth calling out here:
- The Docker image bakes a random session secret into the image at build time
(
.secrets/session-secret), then persists it via a volume — so it's unique per build but stable across restarts of the same deployed instance. - The container starts as
rootsodocker-entrypoint.shcan fix volume ownership on every boot, then drops to an unprivilegedappuser viagosubefore running the server. utils/buildInfo.jscomputes version/commit/release-date once at startup (frompackage.json,RELEASE_NOTES, andgit, best-effort) and exposes it to every view viares.locals.buildInfo— it's how the footer/about popup show what's actually running.