Appearance
How the Web App Is Deployed
This page follows the Cyber Range web app (the Next.js portal, together with the FastAPI provisioning API and the terminal bridge it depends on) from a commit on GitHub to a page in a student's browser. It covers the one-time setup that put it on the internet, how each release reaches the host, and how it runs there. The pipeline mechanics are in Release Pipeline; the services and config files are in Host, Services & Config.
The short version
- Build off the host. GitHub Actions builds the portal (
npm ci && npm run build) and packs the code into a release. The Ampere host never compiles the portal. - Promote. A maintainer publishes the build as the GitHub release
ampere-live. - Pull. A timer on the host checks that release every 5 minutes, and when the commit changes it backs up the current version, unpacks the new one, installs production dependencies, restarts the services and health-checks them, rolling back if anything fails.
- Serve.
next startserves the portal on a private address (10.115.77.1:3000). nginx in theguacamolecontainer puts the portal, Keycloak (/auth/) and the terminal WebSocket on one origin, and a Cloudflare Tunnel that the host dials outbound publishes that origin over HTTPS. No inbound web port is open on the VM.
End-to-end path
1. Build (GitHub Actions)
.github/workflows/ampere-release.yml runs on every push to main, and on manual dispatch:
| Step | What it does |
|---|---|
| Check configs | Fails if postcss.config.js, tailwind.config.js or package-lock.json is missing (a build without them serves unstyled pages) |
| Dependency guard | python3 deploy/host/check_runtime_deps.py fails the build if the API imports a package not declared in pyproject.toml (added by pull request 143) |
| Pack tree | pack_tree.sh bundles the cyberrange/ tree into tree.tgz (code, scripts, units, guides; no secrets, data or docs-site/) |
| Build portal | npm ci && npm run build with Node 20, then portal-build.tgz with .next (minus its cache), public, next.config.mjs, package.json and package-lock.json |
| Manifest | manifest.json with the commit sha, the package-lock.json SHA-256 and the build time |
| Artifact | All three files kept for 14 days |
| Publish | Only on a manual dispatch with promote=true on main: force-tag ampere-live at the commit and recreate the release with the three files |
A second workflow, .github/workflows/portal-typecheck.yml (pull request 269), runs npx tsc --noEmit on the portal for every pull request, so a type error is caught before merge instead of breaking the release build.
Why the portal is never built on the host
The CI runner is x86-64 and the host is Arm, but the .next output is JavaScript and runs on either; Arm-specific native modules come from the host's own npm ci --omit=dev. Building on the host is avoided on purpose: a Next.js build needs more memory than the 12 GiB VM has free once LXD, Keycloak and Wazuh are running (manual 05), and the host's node_modules is production-only, so TypeScript is absent and npm run build fails there with Module not found: Can't resolve '@/...' even on untouched code. A working .next only ever arrives prebuilt.
To promote the current main (maintainers):
bash
gh workflow run ampere-release.yml --ref main -f promote=true2. Apply on the host (pull_release.sh)
cyberrange-pull-release.timer runs deploy/host/pull_release.sh 2 minutes after boot and every 5 minutes after that. Each run:
- Fetches the
ampere-liverelease by tag from the GitHub API and downloads the three files. - Compares the manifest
shawith~/cyberrange/DEPLOYED_SHA. Same commit: logspull-release skip: unchangedand only re-checks the Wazuh certificate (pin sync, plus the CA smoke when the internal CA is installed). A commit that failed before: logs a loudSTUCKline and stops until an operator runspull_release.sh --retry-bad. - Snapshots the running tree and portal build into
~/cyberrange-data/rollback/. - Extracts
tree.tgzinto~/cyberrangeandportal-build.tgzinto~/cyberrange/portal. The extract overwrites but does not delete, and it keeps the host's owncerts/wazuh-api.crt. If the host has the Wazuh internal CA (certs/wazuh-api-ca.crt), the live manager certificate must chain to it and the scoring user must sign in, or the release is rolled back. - Verifies the lockfile hash against the manifest; runs
npm ci --omit=devif the lockfile changed, and installspyproject.tomldependencies into~/cyberrange/.venvif that file changed. - Restarts the API, portal and bridge user services and polls their health (API
/health, portal/login, the NextAuth providers endpoint, the bridge WebSocket handshake) for up to 90 seconds, failing fast if the API crash-loops. - On success, writes the new
DEPLOYED_SHA. On any failure it restores the snapshot, restarts, marks the commit bad and writes the reason torollback/last-failure.log.
Details, including the recovery commands, are in Release Pipeline.
3. Run (systemd user services)
The three app processes run as the operator account llms_admin under systemctl --user, all bound to the host's private bridge address 10.115.77.1:
| Service | Command | Config |
|---|---|---|
cyberrange-portal | npm exec -- next start -H 10.115.77.1 -p 3000 with Node from ~/opt/node | ~/cyberrange/portal/.env.local (Keycloak issuers, NEXTAUTH_URL, NEXTAUTH_SECRET, API_INTERNAL_URL, BROWSER_SCORE_SECRET) |
cyberrange-provision-api | ~/cyberrange/.venv/bin/python provision_api_fastapi.py on :5000 | ~/cyberrange/env/.env |
cyberrange-ssh-bridge | the bridge on :8765 | ~/cyberrange/bridge-src/.env.bridge |
All three restart automatically and log to /tmp/*.log. next start serves from the .next on disk, so never delete the live .next without a verified replacement: the running process can keep serving from memory, but the next restart would fail.
Health-check the private address
The portal binds 10.115.77.1, not localhost. curl http://localhost:3000 returns nothing even when the site is fine; use curl -s -o /dev/null -w '%{http_code}' http://10.115.77.1:3000/login.
4. Publish (nginx + Cloudflare Tunnel)
| Layer | Where | Role |
|---|---|---|
| nginx | guacamole LXD container, 10.115.77.12:80 | The single origin: / → portal, /auth/ → Keycloak on 127.0.0.1:8083, /api/ssh-websocket → bridge (1-hour read timeout); client_max_body_size 6m for evidence screenshots. It adds no security headers itself; the portal's framing, nosniff and referrer headers come from next.config.mjs (pull request 313). Repo copy: deploy/guacamole/nginx-ampere.conf |
| cloudflared | system unit on the host, token in /etc/cloudflared/token | Dials out to Cloudflare on 443; one public hostname whose origin is http://10.115.77.12:80 |
| Cloudflare edge | Cloudflare | HTTPS certificate, DNS (proxied CNAME to <tunnel-id>.cfargotunnel.com) |
| OCI security list | VCN | Ingress only TCP 22 from the operator's /32; no 80 or 443 |
Because Keycloak is served at /auth/ on the same hostname as the portal, the login callback is same-site and the default SameSite=Lax cookies work. The tunnel must point at nginx, not at the portal: pointing it at :3000 breaks login and the terminal.
5. First-time setup (once per host)
The operator manual (manual/README.md) stands the host up in order. The parts that make the web app reachable:
| Chapter | What it sets up for the web app |
|---|---|
| 00, 01 | OCI Ampere instance, SSH, VCN with only port 22 open |
| 02 | LXD, OVN, the guacamole gateway container with nginx, and the Wazuh manager |
| 04 | Keycloak realm and clients, demo accounts, the operator's Node at ~/opt/node, user-management client (Step 9), and the first portal build made off-host and shipped (Step 5) |
| 05 | Provisioning API, portal .env.local, terminal bridge, DVWA proxy |
| 07 | Gate first: prove a full student session on the private address over an SSH tunnel. Then create the Cloudflare Tunnel, add the one public hostname (origin 10.115.77.12:80), probe from outside that every direct port times out, and flip NEXTAUTH_URL and the browser-side Keycloak issuer to https://<host> (the server-side issuer stays on the private address). Add the HTTPS redirect URI to the portal client. |
| 08 | Verification checklist |
After that, the release pipeline above handles code updates. Realm changes do not ship with releases: MFA, the login theme and the password policy are applied once per host with enable_keycloak_mfa.sh, install_keycloak_theme.sh and enable_keycloak_password_policy.sh (see Login, Sessions & Tokens). Wazuh hardening does not ship with releases either: harden_wazuh_api.sh (manual 06, Step 2), setup_wazuh_provisioner.sh (Step 3b) and issue_wazuh_internal_ca.py --install are run once per host.
What a deploy does not change
- Data:
~/cyberrange-data(SQLite database, evidence screenshots, rollback snapshots, release token) is never in a release. - Secrets: the three env files and Keycloak's admin env stay on the host.
- systemd units: a release ships unit files but does not install them; after a unit change, run
deploy/systemd/install-user-units.shonce. - Golden images, nginx, Keycloak and Wazuh config: changed by hand or by their own scripts, then copied back to the repository. The internal CA's private keys under
~/cyberrange-data/wazuh-ca/are never in a release. - This docs site: deployed separately to Cloudflare Pages; see Hosting These Docs.
Sources
.github/workflows/ampere-release.yml, portal-typecheck.yml, cyberrange/deploy/host/pull_release.sh, pack_tree.sh, issue_wazuh_internal_ca.py, harden_wazuh_api.sh, setup_wazuh_provisioner.sh, release_sync.py, check_runtime_deps.py, cyberrange/deploy/systemd/cyberrange-portal.service, cyberrange-provision-api.service, cyberrange-pull-release.service, .timer, cloudflared.service, cyberrange/deploy/guacamole/nginx-ampere.conf, cyberrange/portal/next.config.mjs, manual/05-provisioning-api-and-portal.md, manual/07-deployment-and-cutover.md, manual/README.md.