Nexvora Sign up to download →

Nexvora — Standalone On-Premises Installation Guide

The exact guide our own team and clients follow — also included inside the installer download.

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:

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

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

VM guest


Step 1 — Provision the Deployment on CGC's central instance

On CGC's own central Nexvora instance (not the client's box):

  1. If the client doesn't already have a Tenant row here representing them, create one first — Tenants in the sidebar (Tenants & Staff group). This is the CENTRAL tenant record this license bills against; it is unrelated to whatever Tenant row the client's own on-prem database will have.
  2. Go to Deployments (System group) → Create.
  3. 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, and grace_period_days (defaults to 14).
  4. 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:

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 .env edits just need docker restart nexvora_php nexvora_horizon nexvora_scheduler to take effect (Laravel reads the bind-mounted .env file itself at boot). The exception is the small set of variables docker-compose.yml's php: service explicitly injects as container env vars (APP_KEY, APP_URL, DB_*, RADIUS_REST_SECRET) — those need docker 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

  1. Navigate to https://<APP_DOMAIN>/admin, log in with the admin email/password you set in Step 6's nexvora:onprem-init. (There is no super_admin login for the client — see Step 6.)
  2. Add a NAS device (IP address + shared secret) under your organization.
  3. 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:


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