Skip to content

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.

ExplainerHow the web app ships1:20

The short version ​

  1. 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.
  2. Promote. A maintainer publishes the build as the GitHub release ampere-live.
  3. 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.
  4. Serve. next start serves the portal on a private address (10.115.77.1:3000). nginx in the guacamole container 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:

StepWhat it does
Check configsFails if postcss.config.js, tailwind.config.js or package-lock.json is missing (a build without them serves unstyled pages)
Dependency guardpython3 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 treepack_tree.sh bundles the cyberrange/ tree into tree.tgz (code, scripts, units, guides; no secrets, data or docs-site/)
Build portalnpm 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
Manifestmanifest.json with the commit sha, the package-lock.json SHA-256 and the build time
ArtifactAll three files kept for 14 days
PublishOnly 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=true

2. 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:

  1. Fetches the ampere-live release by tag from the GitHub API and downloads the three files.
  2. Compares the manifest sha with ~/cyberrange/DEPLOYED_SHA. Same commit: logs pull-release skip: unchanged and 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 loud STUCK line and stops until an operator runs pull_release.sh --retry-bad.
  3. Snapshots the running tree and portal build into ~/cyberrange-data/rollback/.
  4. Extracts tree.tgz into ~/cyberrange and portal-build.tgz into ~/cyberrange/portal. The extract overwrites but does not delete, and it keeps the host's own certs/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.
  5. Verifies the lockfile hash against the manifest; runs npm ci --omit=dev if the lockfile changed, and installs pyproject.toml dependencies into ~/cyberrange/.venv if that file changed.
  6. 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.
  7. On success, writes the new DEPLOYED_SHA. On any failure it restores the snapshot, restarts, marks the commit bad and writes the reason to rollback/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:

ServiceCommandConfig
cyberrange-portalnpm 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-bridgethe 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) ​

LayerWhereRole
nginxguacamole LXD container, 10.115.77.12:80The 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
cloudflaredsystem unit on the host, token in /etc/cloudflared/tokenDials out to Cloudflare on 443; one public hostname whose origin is http://10.115.77.12:80
Cloudflare edgeCloudflareHTTPS certificate, DNS (proxied CNAME to <tunnel-id>.cfargotunnel.com)
OCI security listVCNIngress 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:

ChapterWhat it sets up for the web app
00, 01OCI Ampere instance, SSH, VCN with only port 22 open
02LXD, OVN, the guacamole gateway container with nginx, and the Wazuh manager
04Keycloak 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)
05Provisioning API, portal .env.local, terminal bridge, DVWA proxy
07Gate 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.
08Verification 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.sh once.
  • 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.

Cyber Range capstone documentation (Capstone 2 · Group 2 · A4102). Documents main at commit 6971373.