Deployment¶
Four deployment modes ship:
| Mode | Where | Backend | AI calls | Key source |
|---|---|---|---|---|
| Local dev | make dev |
FastAPI on :18001 | Server-side | env / secrets.yaml / DB |
| GitHub Pages | astrapi69.github.io/adaptive-learner/ |
None (Dexie) | Browser-direct | DB (IndexedDB) |
| Desktop launcher | PyInstaller binary (Docker-based) | FastAPI in a Docker container | Server-side | .env (auto-generated) / Settings UI |
| Docker | Docker Compose self-host | FastAPI in container | Server-side | env / Settings UI |
Local development¶
Starts backend (FastAPI + uvicorn --reload) on port 18001
and frontend (Vite dev server) on port 15174 in parallel.
Press Ctrl-C once to stop both.
Both ports are configurable: override ADAPTIVE_LEARNER_PORT
(backend) and ADAPTIVE_LEARNER_FRONTEND_PORT (frontend) in the
environment, or pass make BACKEND_PORT=… FRONTEND_PORT=… dev.
The defaults (18001 / 15174) are intentionally non-standard so
Adaptive Learner coexists with other projects already bound to
8000 / 5173.
The frontend's Vite proxy forwards /api/* to the backend, so
the frontend always uses /api as its base URL - no CORS
config needed for local dev.
For background mode:
GitHub Pages (Dexie-only)¶
.github/workflows/deploy-gh-pages.yml builds the frontend
with:
VITE_BASE="/adaptive-learner/"- prefixes every asset URL for the per-repo Pages path.VITE_STORAGE_MODE="dexie"- pins DexieStorage as the default mode.VITE_API_BASE=""- no backend to point at.
The workflow runs on every push to develop (the active
development branch under gitflow) and on manual dispatch. After
build it copies dist/index.html to dist/404.html for the
SPA-router fallback, then uses actions/upload-pages-artifact@v5
+ actions/deploy-pages@v5 to publish.
The result is a fully static, backend-free build: DexieStorage holds the canonical data in IndexedDB, AI calls go browser-direct to the provider, and the lesson content is bundled into the build so the site works offline.
The site URL is https://astrapi69.github.io/adaptive-learner/.
Custom-domain users add a CNAME file to frontend/public/
with the domain name; GitHub's domain-aware Pages routing
takes care of the rest.
Docker Compose (full stack)¶
There are two compose files:
docker-compose.yml(dev): mounts the source tree, runs uvicorn--reloadand the Vite dev server, publishing the dev ports (backend${ADAPTIVE_LEARNER_PORT:-18001}, frontend${ADAPTIVE_LEARNER_FRONTEND_PORT:-15174}).docker-compose.prod.yml(production), used bymake prod:
docker-compose.prod.yml ships one service, app (single
container since #2058 - there is no nginx and no separate
frontend container):
- FastAPI (Python 3.12-slim image) serves BOTH the built
frontend statics (SPA fallback, 50M body limit and gzip live as
middleware - the retired nginx service's feature set) and
/api/*, with--workers 2on the internal port${ADAPTIVE_LEARNER_BACKEND_PORT:-8000}. The internal port is an implementation detail decoupled from the host-published one. - The host-published port is
${ADAPTIVE_LEARNER_BIND_ADDRESS:-127.0.0.1}:${ADAPTIVE_LEARNER_PUBLIC_PORT:-8501}, mapped straight onto the backend port - this is the address the user reaches in the browser. Loopback by default; seeADAPTIVE_LEARNER_BIND_ADDRESSbelow before exposing it. - A named
adaptive-learner-datavolume mounted at/app/data(set viaADAPTIVE_LEARNER_DATA_DIR) that survives container rebuilds. The DB lives at$DATA_DIR/adaptive_learner.dband uploads at$DATA_DIR/uploads/.
The image runs as a non-root user (adaptive_learner,
created in backend/Dockerfile).
install.sh and install.ps1 are the curl-pipe installers
for end users - they clone the tagged release (tarball download
as the git-less fallback), set up
ADAPTIVE_LEARNER_SECRET_KEY, and docker compose up.
start.sh is the equivalent local entry point: it checks Docker,
generates a random secret into .env from .env.example when no
.env exists, then brings up the prod stack.
The installers are regenerated at release time from
install.sh.template / install.ps1.template plus
backend/pyproject.toml's version (see scripts/sync_versions.py).
Don't edit the generated files directly.
Configuration for production¶
Four things matter for prod:
ADAPTIVE_LEARNER_SECRET_KEY: must be a stable Fernet key. Generate once, store it somewhere safe (HashiCorp Vault, AWS Secrets Manager, a sealed.env). Losing it means all encrypted API keys become unreadable. The app fails hard at startup if it's unset (no silent default). For the Docker stack,start.sh/ the launcher auto-generate a random key into.envon first run when none exists.ADAPTIVE_LEARNER_CORS_ORIGINS: comma-separated list of allowed origins. Default is permissive; tighten it down for prod.ADAPTIVE_LEARNER_DEBUG: leave unset / false in prod. Debug mode exposes stack traces in error responses.ADAPTIVE_LEARNER_BIND_ADDRESS: default127.0.0.1, so the published port is reachable only from the host itself. The app has no authentication - bind0.0.0.0only deliberately, and only in a trusted network or behind your own auth layer (reverse proxy with basic auth, VPN).
For containers, env vars are the idiomatic injection channel.
The ~/.config/adaptive_learner/secrets.yaml overlay is
meant for desktop / launcher use; you can bind-mount it
into a container too if you prefer one config file over
several env vars.
Desktop launcher (cross-OS, Docker-based)¶
launcher/ is a PyInstaller-based one-binary desktop launcher.
It is not an embedded server - it is a thin wrapper around
the published docker-app-launcher engine, configured by
launcher/launcher.json. The shipped config runs in image
mode (deployment_mode: "image"): the launcher pulls the
ready-built, verified release image
(ghcr.io/astrapi69/adaptive-learner:<version>, pinned to the
embedded app version) and starts it as a Docker container -
nothing is built, downloaded as source, or extracted locally.
The flow is intentionally linear:
- Check that Docker is installed and running (clear error dialogs guide the user to install/start Docker otherwise).
- Pull the pinned release image from GHCR when it is not already present.
- Start the container with the named
adaptive-learner-datavolume mounted at/app/data, so data survives updates. - Wait for the backend health check, then open the user's default browser at the published port.
- On user-controlled stop, stop the container.
The launcher embeds the target version (__version__ literal +
_build_info.py written by the spec file at build time;
source-of-truth is backend/pyproject.toml). The engine also
runs a background update check against the GitHub Releases
API (enabled via update_check_enabled in launcher.json): it
queries /repos/.../releases/latest, and only when a strictly
newer release exists does it notify the user. The check fails
silently on any error (no network, GitHub down, rate limit,
malformed response) so it never blocks or interrupts the
launcher.
GitHub Actions builds three binaries per release:
launcher-linux.yml→adaptive-learner-launcher(Linux)launcher-macos.yml→adaptive-learner-launcher(macOS)launcher-windows.yml→adaptive-learner-launcher.exe
The launcher is intentionally not the primary distribution channel (Docker is). It exists for users who want a "double- click to install" experience without typing compose commands.
The full three-layer config chain (project YAML < user overlay
< env vars) is documented in docs/configuration.md.
CI/CD architecture¶
Each workflow runs in isolation; no shared state between them:
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
push / PR to develop, main |
Tests + lint + tsc |
coverage.yml |
daily schedule, dispatch | Coverage HTML + xml |
release-gate.yml |
v*.*.* tag push, dispatch |
Version pin drift check |
deploy-gh-pages.yml |
push to develop, dispatch |
GH Pages build + deploy |
launcher-{linux,macos,windows}.yml |
release: created | Build + attach launcher binary |
dexie-smoke.yml |
daily schedule, release/**, dispatch |
Dexie-mode route smoke gate |