What this delivers
A large client (many subscribers) runs Nexvora on their own server
instead of as a tenant on CGC's shared multi-tenant instance, while CGC
still centrally licenses and bills them for it. It's the exact same
Docker Compose stack and codebase as any other Nexvora install — the
only on-prem-specific pieces are three extra .env values and one
extra scheduled job that phones home to CGC's central instance once a
day (see Step 9).
Two billing relationships stay deliberately separate:
- CGC bills the client for their Nexvora license — this is what
gets centralized (
Deployment/DeploymentCheckIn/LicenseStatemodels,App\Services\Licensing\LicenseEnforcementService). - The client bills their own subscribers — stays entirely local to their box, same as any other Nexvora install. CGC never sees it.
Every step below is a manual, deliberate action by a superadmin or the on-prem installer — matching this app's existing house style everywhere else (no self-service on-prem signup, no auto-billing).
Prerequisites
- Ubuntu 22.04 / Debian 12 server (minimum 4 vCPU, 8 GB RAM, 100 GB SSD — see below on sizing this up for real subscriber volume)
- Docker >= 24.0 and Docker Compose >= 2.20
- A domain the client controls, with DNS pointed at this server (a
wildcard record —
*.subdomain.theirdomain.com → server IP— if they want per-branch subdomains; a single A record is enough otherwise) - Ports open inbound: 80/tcp, 443/tcp, 1812/udp, 1813/udp, 3799/udp
- Outbound HTTPS access to CGC's central domain (for the daily license check-in — see Step 9)
/etc/machine-idpresent on the host (standard on virtually every modern Linux distro since systemd — check withcat /etc/machine-id).docker-compose.ymlbind-mounts this read-only into the app containers so the license can be bound to this specific host (see "License binding and duplicate detection" below); Docker silently mounts an empty directory instead of erroring if the host file is missing, so check this now rather than debugging it later. On a VM guest cloned from a template or snapshot, regenerate this before going further — see "Choosing and preparing the host" just below.
Sizing note: the stock PHP-FPM/MySQL defaults this stack ships
with were undersized for anything beyond light testing — already
fixed in this repo (docker/php/www-pool.conf,
docker/mysql/my.cnf's innodb_buffer_pool_size), but re-check both
against the box's actual RAM once real subscriber volume is known;
see the comments in those two files for the sizing reasoning to adapt.
Choosing and preparing the host: VM guest vs. physical server
Both are fully supported — it's the same Docker Compose stack either way, so this is about the client's own infra preferences and budget, not a Nexvora limitation. A few things differ in how you get each one ready before Step 1.
Physical (bare-metal) server
- Fresh Ubuntu 22.04 / Debian 12 install, per Prerequisites' sizing.
- RAID 1 (or better) on the disk MySQL's data volume lives on is strongly recommended — this box is the sole authority for RADIUS auth/accounting and every subscriber's billing data, with no central fallback if its disk fails (unlike a SaaS tenant, which sits on CGC's own backed-up infrastructure).
- Put the NIC that will carry RADIUS traffic (UDP 1812/1813/3799) on
the same network segment as the routers/APs authenticating against
it, or confirm whatever routing sits between them adds negligible
latency/jitter — FreeRADIUS' own hard 4-second timeout has little
room to spare once TLS, PHP-FPM, and MySQL round-trips are already
accounted for (see
CLAUDE.md's RADIUS bridge section). - A UPS (or the datacenter's equivalent) is worth it for the same reason as the RAID note — an ungraceful power loss mid-write is a real risk to MySQL's data directory without one.
VM guest
- Networking: bridged, or routed with explicit port forwarding — never NAT-only. RADIUS is UDP, and UDP NAT traversal is unreliable at best. This app needs 80/tcp, 443/tcp, 1812/udp, 1813/udp, 3799/udp all individually reachable from the outside (see Prerequisites). If the hypervisor itself sits behind a NAT/firewall (common for a VM on a home/office network), forward each port explicitly rather than relying on a single "expose the VM" toggle.
- Static IP or a DHCP reservation. NAS device records store this server's IP directly (Step 10); letting it drift silently breaks every already-configured router.
- Don't overcommit CPU on this VM's host. FreeRADIUS' 4-second budget assumes it actually gets scheduled promptly — heavy CPU contention from sibling VMs (steal time) is a realistic way to turn an otherwise-healthy install into intermittent RADIUS timeouts under load, and it's easy to misdiagnose as an app bug rather than a hypervisor scheduling problem.
- Regenerate
/etc/machine-idif this VM came from a template, snapshot, or clone — before Step 6, not after:
Every VM cloned from the same template shares the same machine-id until this is done. The license binds to it the first time this install successfully checks in (Step 9) — doing this beforehand means it binds to a value that's actually unique to this VM, not to whatever template it came from. See License binding and duplicate detection below for what this protects against and its real limits.sudo rm -f /etc/machine-id sudo systemd-machine-id-setup cat /etc/machine-id # confirm it's populated and looks unique - Live-migrating this VM between physical hosts (vMotion,
virsh migrate, etc.) does not trigger a fingerprint mismatch — machine-id lives in the guest, not tied to the physical host under it. Restoring a snapshot as a second, simultaneously-running copy is exactly the case this is meant to catch.
Step 1 — Provision the Deployment on CGC's central instance
On CGC's own central Nexvora instance (not the client's box):
- If the client doesn't already have a
Tenantrow here representing them, create one first — Tenants in the sidebar (Tenants & Staffgroup). This is the CENTRAL tenant record this license bills against; it is unrelated to whateverTenantrow the client's own on-prem database will have. - Go to Deployments (
Systemgroup) → Create. - Fill in the client's tenant, a descriptive name (e.g. "Acme ISP —
HQ server"), the plan tier they're licensed for (same tiers as
config/license_tiers.php— starter/growth/business/scale/ enterprise),expires_at, andgrace_period_days(defaults to 14). - Save. A notification shows the plaintext deployment key exactly once — copy it now. It is never stored anywhere and never shown again; if it's lost, delete this Deployment and create a new one rather than trying to recover the key.
Automated install
scripts/onprem-install.sh automates Steps 2–9 below (everything from
"code is on the box" through "license check-in confirmed") — it's the
same commands, run in the same order, with generated secrets in place
of hand-typing strong passwords. Steps 1 (above) and 10 (below) are
deliberately not automated: both need a real decision only a human
makes (which central tenant/plan this bills against; the client's
actual first customer and NAS device), matching this app's existing
policy of no unattended action on anything billing- or tenant-related.
cd nexvora # after Step 2's clone/extract
./scripts/onprem-install.sh
It's interactive — prompts for the domain, super admin email, SSL
contact email, and (optionally, can be added to .env later instead)
the central URL/deployment key from Step 1. Everything else
(APP_KEY, DB/Redis passwords, RADIUS_REST_SECRET, and the super
admin password if left blank) is generated and written into .env
automatically — the generated super admin password is shown once at
the end, the same one-time-reveal convention Deployments → Create
already uses for the deployment key itself.
It also runs Step 6's nexvora:onprem-init directly in your terminal,
which separately prompts for the client's organization name and their
first admin's name/email/password — this is the account you'll
actually use in Step 10, distinct from the .env super admin above
(which gets deactivated as part of this same step).
Safe to re-run: it never overwrites a .env value that's already
set, and skips db:seed outright (rather than crashing on a
duplicate demo tenant) if this instance shows signs of having been
seeded before.
Flags: --skip-ssl (skip Step 7 if DNS isn't pointed here yet — run
docker compose --profile ssl run --rm certbot manually once it is)
and --keep-demo-tenant (skip removing the seeded demo account —
see Step 6's note on why that removal matters for a real client box).
The steps below are what the script runs — useful as the reference for what's actually happening, or to run any individual piece by hand if something needs troubleshooting.
Step 2 — Get the code onto the client's server, and configure .env
cgcpro/NEXVORA is a private GitHub repo — an external client
shouldn't need ongoing access to CGC's own source history to run their
own instance. Two ways to get the code there:
- CGC-internal / CGC-managed install: clone directly, using a
deploy key or PAT with access to the private repo:
git clone git@github.com:cgcpro/NEXVORA.git nexvora cd nexvora - Handing off to the client: ship a source tarball instead — no
GitHub access needed on their end. Generate one from the repo root:
git archive HEAD | tar -x -C /tmp/nexvora-onprem-build rm -rf /tmp/nexvora-onprem-build/laravel-base /tmp/nexvora-onprem-build/scripts/acme tar -czf nexvora-onprem-$(git rev-parse --short HEAD).tar.gz -C /tmp/nexvora-onprem-build .git archiveonly includes tracked files, so.env,vendor/,node_modules/, and everything else in.gitignorecan never leak into it by omission.laravel-base/(an unused scaffold) andscripts/acme/(CGC's own domain-specific wildcard-SSL automation) are tracked but irrelevant to a client install, so they're stripped explicitly —scripts/onprem-install.sh(see Automated install below) stays in. Transfer the resulting file however's appropriate for the client (signed download link, SCP, etc.), send itssha256sumalongside it so they can verify the transfer wasn't corrupted, then on their server:mkdir nexvora && tar -xzf nexvora-onprem-*.tar.gz -C nexvora && cd nexvora
Either way, once the code is in place:
cp .env.example .env
nano .env
Every value in .env.example has a comment explaining it; at minimum,
set these before starting anything:
| Variable | Set to |
|---|---|
APP_URL, APP_DOMAIN, APP_BASE_DOMAIN |
the client's own domain |
DB_PASSWORD, DB_ROOT_PASSWORD |
strong, unique passwords |
REDIS_PASSWORD |
a strong password |
RADIUS_REST_SECRET |
a strong random string, 32+ chars |
SUPER_ADMIN_EMAIL, SUPER_ADMIN_PASSWORD |
the seeded platform account, deactivated immediately in Step 6 — do not leave this blank; DatabaseSeeder falls back to a well-known default password (ChangeMe@2024!) if it's empty, and a strong value here matters until it's deactivated |
NEXVORA_HOST_PUBLIC_IP |
this server's own public IP (used as the WireGuard/L2TP tunnel endpoint, if the client uses remote-site tunnels) |
NEXVORA_CENTRAL_URL, NEXVORA_DEPLOYMENT_KEY |
leave blank for now — set in Step 9, after the app is actually running |
Payment gateway / SMS / email credentials can be left blank here — the
client configures those later, per-tenant, from System →
Notification Settings and Billing → Payment Gateways in their
own admin panel, not via .env.
Step 3 — Generate APP_KEY
Laravel's APP_KEY just needs to be a base64-encoded 32-byte string,
prefixed base64:. Generating it before the stack is up avoids a
container-recreate step later (Docker only re-reads .env for the
handful of variables docker-compose.yml explicitly injects — see the
note at the end of Step 4 — at container creation time, not
continuously):
echo "APP_KEY=base64:$(openssl rand -base64 32)"
# Paste the output as the APP_KEY= line in .env
Step 4 — Bring up the stack
docker compose up -d
docker compose ps
Expect every service Up (mysql/redis show (healthy) once their
healthchecks pass, usually within ~30s):
nexvora_nginx Up 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
nexvora_php Up
nexvora_horizon Up
nexvora_scheduler Up
nexvora_freeradius Up
nexvora_mysql Up (healthy)
nexvora_redis Up (healthy)
nexvora_php will report healthy at the container level immediately,
but real HTTP requests will 502 until Step 5 installs its
dependencies — expected, not a problem.
Config changes after this point: most
.envedits just needdocker restart nexvora_php nexvora_horizon nexvora_schedulerto take effect (Laravel reads the bind-mounted.envfile itself at boot). The exception is the small set of variablesdocker-compose.yml'sphp:service explicitly injects as container env vars (APP_KEY,APP_URL,DB_*,RADIUS_REST_SECRET) — those needdocker compose up -d(which recreates the container to pick up the new value) rather than a plain restart.
Step 5 — Install dependencies and build frontend assets
docker exec nexvora_php composer install --no-interaction --optimize-autoloader
# Frontend assets (captive portal CSS/JS) -- the profile-gated `node`
# service, not installed on the host or in the php image by design.
# `node_modules/` is gitignored, so npm ci must run before the build
# on a fresh install -- the build step alone (`vite build`) doesn't
# install its own dependencies first:
docker compose run --rm node npm ci
docker compose run --rm node
Step 6 — Migrate, seed, and provision the client's organization
docker exec -u www-data nexvora_php php artisan migrate --force
docker exec -u www-data nexvora_php php artisan db:seed --force
docker exec -u www-data nexvora_php php artisan storage:link
docker exec -u www-data nexvora_php php artisan optimize
(Run as -u www-data so cached files under bootstrap/cache/storage
come out correctly owned — running as root here is the single most
common source of a later "admin panel randomly 500s/419s" support
call on this app.)
db:seed creates the platform super_admin account from
SUPER_ADMIN_EMAIL/SUPER_ADMIN_PASSWORD in .env, the standard
roles/permissions, and a "Demo ISP" tenant with well-known,
publicly-documented credentials (DatabaseSeeder.php: tenant admin
admin@demoisp.com / Demo@12345!, RADIUS test user testuser /
testpassword, a fake NAS device) — fine for CGC's own internal/demo
instances, a real liability left live on a client's production box.
Neither of those is what the client should actually be handed. Run the
guided provisioning command next — it creates the client's real
organization and their first admin account (a tenant_admin, which
already has everything needed: StaffResource lets them manage their
own staff, every subscriber/NAS/billing resource is tenant-scoped),
removes the demo tenant, and deactivates (never deletes) the
seeded super_admin — a client never needs the platform-operator
account meant for Connect Globa Consults' own ops team:
docker exec -it -u www-data nexvora_php php artisan nexvora:onprem-init
You'll be prompted for the organization's name/vertical/country and
the first admin's name/email/password. This is the account you'll log
into in Step 10 — there's no separate super_admin login step anymore.
Safe to skip if you genuinely want to keep the demo tenant for your
own testing: add --keep-demo-tenant.
Step 7 — SSL
docker compose --profile ssl run --rm certbot
docker exec nexvora_nginx nginx -s reload
This issues a Let's Encrypt cert for APP_DOMAIN (and www.) via
HTTP-01 challenge, using the SSL_EMAIL set in .env — requires port
80 reachable from the internet and DNS already pointed at this server.
If the client wants per-branch subdomains under a wildcard, that needs
a DNS-01 challenge instead (requires their DNS provider's API, or a
delegated setup like the one PHASE1_DEPLOYMENT.md documents for
CGC's own domain — provider-specific, set up per client if needed, not
a generic on-prem step).
Step 8 — Verify the RADIUS bridge
radtest testuser testpassword 127.0.0.1 0 <your RADIUS_REST_SECRET>
# Expect: Access-Reject with "Unknown NAS device" (nothing is registered yet -- correct)
docker exec nexvora_freeradius tail -f /var/log/freeradius/radius.log
docker exec nexvora_php tail -f storage/logs/laravel-$(date -u +%F).log
Step 9 — Configure the license check-in
Now that the app is actually running, add the values from Step 1:
nano .env
# NEXVORA_CENTRAL_URL=https://<CGC's central domain>
# NEXVORA_DEPLOYMENT_KEY=<the plaintext key from Step 1>
docker restart nexvora_php nexvora_horizon
Confirm the check-in works rather than waiting for its 04:00 daily schedule:
docker exec -u www-data nexvora_php php artisan tinker --execute="
(new App\Jobs\Licensing\DeploymentCheckInJob())->handle();
\$s = App\Models\LicenseState::current();
echo 'status=' . \$s->status . ' plan=' . \$s->plan . ' last_synced_at=' . \$s->last_synced_at . PHP_EOL;
echo 'last_sync_error=' . (\$s->last_sync_error ?? 'none') . PHP_EOL;
"
Expect last_sync_error empty and status/plan matching what was
set in Step 1. On CGC's central instance, confirm the Deployment's
Last check-in column updated.
If last_sync_error shows a connection failure: check this box's
outbound HTTPS access to CGC's central domain (firewall/proxy), and
that NEXVORA_CENTRAL_URL has no trailing slash or typo. If it shows
a 401/403: the deployment key is wrong, or the Deployment was revoked
centrally — re-copy the key from a freshly-created Deployment rather
than trying to recover a lost one.
Step 10 — First login and NAS device
- Navigate to
https://<APP_DOMAIN>/admin, log in with the admin email/password you set in Step 6'snexvora:onprem-init. (There is no super_admin login for the client — see Step 6.) - Add a NAS device (IP address + shared secret) under your organization.
- Point the actual router/access point's RADIUS config at this
server:
- Auth server: this server's IP, port 1812
- Acct server: this server's IP, port 1813
- Secret: the secret set on the NAS device record
Renewing the license
The on-prem instance's own tenant admin renews from their own panel:
Billing → Renew License (only visible once Step 9 is configured).
Picking a payment method redirects to checkout on CGC's central
domain, billed to CGC's own merchant account — this instance never
needs its own payment gateway credentials for this. A completed
payment extends Deployment.expires_at by 365 days from whichever is
later, now or the current expiry, so renewing early never loses
already-paid-for time.
For the first few clients, CGC's ops team can just extend
Deployment.expires_at by hand from the central Deployments
resource after being paid through any other channel (invoice, bank
transfer) — the self-service renewal flow above is a convenience, not
a requirement.
What happens if a license lapses
The one part of this system that changes actual behavior on the
on-prem box — see LicenseEnforcementService's class docblock for the
full reasoning. In short:
- Grace period (missed check-ins, or CGC explicitly grants one): nothing changes. A warning banner appears on the dashboard ("License check-in overdue" / "renew before —").
- Expired (grace period exceeded, or CGC explicitly revokes): new subscribers, NAS devices, and VPN tunnels can no longer be created until renewed. Every already-provisioned subscriber keeps authenticating via RADIUS exactly as before — deliberate and load-bearing, not an oversight: the RADIUS accept/reject decision never reads the flag this system sets, so a lapsed CGC license can never cut off the client's own paying customers.
- Never activated at all: an install that completes Step 6
(
nexvora:onprem-init) but never configures Step 9's central check-in — whether by mistake or deliberately, to avoid ever being subject to license enforcement — has 14 days before it's treated the same as an expired license. This is separate from the grace period above and can't be extended by simply not configuring central check-in; configure Step 9 before this window closes.
License binding and duplicate detection
Each Deployment binds to the host it was first activated on, derived
from /etc/machine-id (see Prerequisites) combined with the
deployment key — never sent or stored in plaintext. If a later
check-in reports a different host (the whole install copied to a
second machine, a VM clone, etc.), CGC's central Deployments
resource flags it for manual review; it does not automatically
suspend or otherwise change anything on either machine — a legitimate
hardware migration produces the exact same signal, so this is always a
human decision on CGC's side, following up with the client before
concluding anything. Genuinely migrating this install to new hardware
is expected to occasionally trigger this flag; that's normal, not an
error.
Reselling (creating and managing sub-tenants under this install) is a SaaS-side capability only and isn't available on-prem — there's no account with access to do this, by design (see Step 6).
Applying updates later
Same rules as any Nexvora deployment (see PHASE1_DEPLOYMENT.md's
"Deploying code changes" section for the full detail) — the short
version: git pull alone is not enough.
git pull
docker compose run --rm node # if frontend assets changed
docker compose restart php horizon scheduler
docker exec -u www-data nexvora_php php artisan migrate --force # if new migrations
docker exec -u www-data nexvora_php php artisan optimize
opcache.validate_timestamps = 0 means a missed restart is a silent
failure — old code keeps running with nothing in the logs to flag it.
Always confirm git log shows the intended commit before running
the restart, not after kicking off the pull.
Appendix: CGC-internal — publishing a new tarball build
If you're installing Nexvora on your own server, this section is not for you — skip it. Everything above (Steps 1-10) is the complete install process; you never need anything below this point. This section is for Connect Globa Consults' own team, publishing a new build of the installer tarball to the landing page's download — unrelated to installing or running Nexvora itself.
The file lives on the app's private disk
(storage/app/private/onprem-downloads/), served only to signed-in
accounts via App\Http\Controllers\OnpremDownloadController — never
under public/, so there's no way to fetch it without going through
that auth check. storage/ is a named Docker volume
(app_storage), not part of the ./app bind mount, so a plain
host-side cp/mv into ./app/storage/... silently goes nowhere the
running container can see — use docker cp (or docker exec)
directly against the container:
docker exec -u www-data nexvora_php mkdir -p /var/www/nexvora/storage/app/private/onprem-downloads
docker cp nexvora-onprem-<sha>.tar.gz nexvora_php:/var/www/nexvora/storage/app/private/onprem-downloads/nexvora-onprem-latest.tar.gz
sha256sum nexvora-onprem-<sha>.tar.gz > nexvora-onprem-latest.tar.gz.sha256
docker cp nexvora-onprem-latest.tar.gz.sha256 nexvora_php:/var/www/nexvora/storage/app/private/onprem-downloads/
docker exec nexvora_php chown -R www-data:www-data /var/www/nexvora/storage/app/private/onprem-downloads
Notes and current limits
- One tenant per on-prem install is the assumed shape for now —
licensing applies to the first-created local
Tenantrow. Multiple sub-organizations under one on-prem install is a possible future extension, not supported today. - No self-service on-prem installer. Provisioning is the manual
steps above; there's no signup flow that creates a
Deploymentand hands out a key automatically. - Wildcard/DNS-01 SSL for per-branch subdomains is provider-specific and not part of this generic runbook — Step 7 covers the single-domain HTTP-01 case, which is enough for most on-prem clients.
- License binding is a deterrent, not a guarantee. A client with
root access to their own server can always defeat host-based
fingerprinting (same fundamental limit as any on-prem license
enforcement) — see
MachineFingerprintService's docblock. It raises the bar against casual copying (VM snapshot, disk image, handing the.envto someone else); it isn't cryptographically unbeatable.