Skip to main content

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-session with the default in-memory store (no Redis/Mongo session store).
  • Auth: local (bcrypt) or LDAP/Active Directory (Passport's passport-ldapauth strategy). No OIDC/SAML/SSO exists in this codebase.
  • CSS: Sass, compiled to a single public/css/main.css at 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)

  1. Load environment (dotenv), read config/config.js for webFQDN, webPort, authMode.
  2. 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.
  3. If authMode === 'ldap', register the Passport LDAP strategy (config/ldap.js). Passport is always initialized (passport.initialize()), but it's session-less — express-session is the actual session mechanism, not Passport sessions.
  4. Configure the EJS view engine, static file serving (public/), body parsing (express.urlencoded, express.json), and methodOverride('_method') so HTML forms can send PUT/DELETE.
  5. Wire Morgan access logs into the Winston logger.
  6. Mount session middleware, then a small locals-injector that sets res.locals.user, res.locals.path, and res.locals.buildInfo on every request (so views never have to ask for them explicitly).
  7. Mount the license gate (middleware/accessGate.js) — before every route, including login.
  8. Mount forced-password-change enforcement (middleware/forcePasswordChange.js).
  9. Mount routers (see Request flow below).
  10. 404 handler, then a generic error handler that renders the error view.
  11. 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 restartServer function 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.jsmiddleware/license.js's requireLicense) — allowlists only /license and /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's mustChangePassword flag is set, every route except /auth/change-password and /auth/logout is 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.jsrequireAuth (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:

MountRoutes fileController(s)Covers
/authroutes/auth.jsauthController.jsLogin (local/LDAP), logout, change password
/exercisesroutes/exercises.jsexercisesController.jsExercise library CRUD, history chart data
/plansroutes/plans.jsplansController.jsPlan CRUD, execution, subscriptions, CSV export/import of your own plans
/adminroutes/admin.jsadminController.js, settingsController.js, importExportController.jsUsers, exercises, sessions, settings, full-instance CSV, license (admin view)
/licenseroutes/licensePages.jslicenseController.jsActivation/trial/checkout pages
/api/licenseroutes/api/license.jslicenseController.jsUnauthenticated trial/activate JSON endpoints (must work with no session, since an unlicensed instance has no working app behind the gate)
/ (root)routes/users.jsusersController.jsProfile, appearance, own-data CSV backup
GET /homeController.jsDashboard

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) holding webFQDN and 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-in X509Certificate and tls.createSecureContext before 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 root so docker-entrypoint.sh can fix volume ownership on every boot, then drops to an unprivileged app user via gosu before running the server.
  • utils/buildInfo.js computes version/commit/release-date once at startup (from package.json, RELEASE_NOTES, and git, best-effort) and exposes it to every view via res.locals.buildInfo — it's how the footer/about popup show what's actually running.