Versus runs on your own infrastructure from the same container image as the hosted service — only the packaging differs. This guide covers all three on-prem form factors, from a single VM you boot in minutes to a full high-availability Kubernetes deployment, plus licensing, identity, storage, backups, air-gap, and upgrades.
All three run the identical image and honor the identical signed .veslic license — pick by need, not by product.
| Form factor | Best for | Database | HA |
|---|---|---|---|
| A · Single-node appliance | Trials, POCs, small/medium prod, air-gapped | Bundled in the VM | Snapshot + backup |
| B · Kubernetes (Helm) | Large-scale HA, teams already on k8s | External / operator Postgres | Automatic (multi-replica, failover) |
| C · Clustered appliances + LB | HA / redundancy without Kubernetes | One shared external Postgres | App: auto via LB · DB: manual (or auto w/ HA Postgres) |
Whichever option you choose, have these ready:
SECRET_KEY and aFIELD_ENCRYPTION_KEY (a Fernet key that encrypts sensitive columns). Escrow the Fernet key — losing it makes encrypted data unrecoverable.versus.yourco.internal)..veslic license — applied in the UI after first boot (see §6).Generate the two secrets from the Versus image:
SECRET_KEY=$(docker run --rm versus/web:onprem \
python -c 'from django.core.management.utils import get_random_secret_key;print(get_random_secret_key())')
FIELD_ENCRYPTION_KEY=$(docker run --rm versus/web:onprem \
python -c 'from cryptography.fernet import Fernet;print(Fernet.generate_key().decode())')You don't have to keep SECRET_KEY, FIELD_ENCRYPTION_KEY or the database credentials in a file — the app reads them from its environment, so any secrets manager that injects the environment works:
secrets.existingSecret=<name> — nothing sensitive lives in values.yaml.envconsul to template the environment from your vault before the stack starts; the host authenticates to the vault with an AppRole or its own machine identity.The secret is fetched at deploy time and injected as environment, so Versus stays vault-agnostic. (Whatever holds the FIELD_ENCRYPTION_KEY must itself be backed up / escrowed — losing it makes encrypted data unrecoverable.)
The requirement is that nothing unencrypted ever crosses the network. Achieve it by terminating TLS on the same server / pod as the app, so the only plain-HTTP hop is over the local loopback interface and never touches the wire:
DATABASE_URL=…?sslmode=verify-full, an HTTPS BACKUP_S3_ENDPOINT(MinIO / S3 over TLS), and an https:// OIDC_ISSUER for identity.The default form factor: one self-contained VM running the Versus web app + a bundled Postgres (+ an optional collector gateway). Two ways to run it.
Import the .ova (VMware/ESXi) or boot the .qcow2 (KVM/Proxmox). Default shape is 4 vCPU / 8 GB / 40 GB — grow disk for larger estates.
On first boot the appliance generates its own unique secrets (Django key, Fernet key, DB password, install fingerprint) and starts the stack. Nothing is baked in — a leaked image exposes nothing.
Browse to https://<appliance-ip>/ — the appliance terminates TLS itself (a co-located Caddy; self-signed cert by default, so expect a browser warning until you install your own). Log in as the admin (local login — no cloud dependency) and apply your .veslic under System → Licensing.
If you'd rather run it on an existing Linux VM with Docker, use the compose stack directly. Load the image, create a .env, and bring it up:
# 1. Load the Versus image (air-gap) or pull from your registry
docker load -i versus-web-onprem.tar # or: docker pull <registry>/versus/web:onprem
# 2. Create .env next to docker-compose.yml
cat > .env <<EOF
VERSUS_IMAGE=versus/web:onprem
DB_PASSWORD=$(openssl rand -base64 24)
SECRET_KEY=$SECRET_KEY
FIELD_ENCRYPTION_KEY=$FIELD_ENCRYPTION_KEY
ALLOWED_HOSTS=*
EOF
# 3. Start (web + bundled postgres, + gateway if you enroll collectors)
docker compose up -d
docker compose logs -f web # watch migrations + startupThe compose file sets VERSUS_DEPLOYMENT_MODE=onprem (turns on license enforcement) and includes a co-located Caddy that terminates TLS and publishes the UI on 443 only (self-signed cert by default) — so the app stays internal and nothing crosses the wire in cleartext. The collector gateway service is optional — remove it if you have no remote collectors.
BACKUP_S3_* at an S3 / MinIO endpoint (see §5) and schedule Postgres dumps off the VM.The production HA form factor: multiple web replicas, a single background worker, scheduledCronJobs, an optional collector gateway, and automatic failover. Same image, same license.
versus/web:<tag> to a registry the cluster can pull (or load it into an air-gapped registry).# Generate the two app secrets (see §2), then:
helm install versus deploy/helm/versus \
--namespace versus --create-namespace \
--set image.repository=<registry>/versus/web \
--set image.tag=0.2.0 \
--set secrets.secretKey="$SECRET_KEY" \
--set secrets.fieldEncryptionKey="$FIELD_ENCRYPTION_KEY" \
--set secrets.databaseUrl="postgres://user:pass@pg-host:5432/versus" \
--set web.replicaCount=3 \
--set ingress.enabled=true \
--set ingress.host=versus.yourco.internal \
--set gateway.enabled=true # only if you enroll remote collectorsPrefer an existing Secret (with keys SECRET_KEY, FIELD_ENCRYPTION_KEY,DATABASE_URL)? Set secrets.existingSecret=<name> and it's used verbatim — nothing sensitive on the CLI.
concurrencyPolicy: Forbid so the non-idempotent ones never overlap.For a no-single-point-of-failure install, run the stateful tiers with operators: a Postgres operator (CloudNativePG — primary + replicas, automatic failover, point-in-time recovery) anddistributed MinIO for object storage (backups + PITR). Point databaseUrl at the operator's pooler service and BACKUP_S3_* at MinIO — giving primary + replicas with automatic failover and point-in-time recovery.
# Ingress host, LoadBalancer IP, or port-forward:
kubectl -n versus port-forward svc/versus-web 8080:80
# then open http://localhost:8080/ and apply the .veslic under System -> Licensinghelm lint deploy/helm/versus
helm template versus deploy/helm/versus \
--set secrets.secretKey=x --set secrets.fieldEncryptionKey=y \
--set secrets.databaseUrl=postgres://u:p@h:5432/d | lessWant redundancy but no Kubernetes? Run two or more app nodes behind your load balancer. The key rule: they must share one database and a few pieces of state — two appliances each with their own embedded DB would be two separate systems, not HA.
Stop using each appliance's bundled Postgres. Stand Postgres up separately (its own VM, a managed service, or the customer's existing Postgres) and point every app node at it. In each node's environment:
# Same shared DB on every app node — all writes go to ONE primary
DATABASE_URL=postgres://versus:[email protected]:5432/versusTo make the database itself resilient, give it its own HA: streaming replication(primary + hot standby) with automatic failover via Patroni / pg_auto_failover / repmgr (or a managed HA Postgres). Put PgBouncer or a floating VIP in front so the app nodes always reach the current primary. Only ever one writable copy.
A few things are per-process and must be handled so the extra nodes don't double-fire or diverge:
| Concern | Do this |
|---|---|
| Schedulers / pollers | Run them on one node only. Set DISABLE_INPROC_SCHEDULERS=1 on the extra node(s), and run the scheduled commands (backups, rotation) from a single node's cron. |
| Vault session tokens | Enable sticky sessions on the LB so a client stays on one node. |
| Device backups | Point BACKUP_S3_* at a shared S3/MinIO on every node (§5). |
| Media / PCAP / firmware | Put the media path on shared storage (NFS) mounted by all nodes. |
| Migrations | Run migrations from one node; set RUN_SEEDS=0 on the others. |
Use an L7 HTTP load balancer (nginx, HAProxy, F5 — the gear you already run): terminate TLS, health-check each node at /api/updates/health/, and enable sticky sessions. The collector gateway is separate (outbound bearer-authenticated TLS on 443) — collectors dial it directly, so it sits beside, not behind, the web LB.
Authentication is built into Versus — there is no separate identity server to deploy. You configure it in the app under System → Roles → Auth Backends by enabling one or more backends. They can be stacked and prioritized; a user is authenticated by the first backend that accepts their credentials, with local always available as a fallback.
OIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET; group mappings resolve on each login.Whichever backends you enable, sign-in happens on the app server over HTTPS — credentials and OIDC redirects never traverse the network in the clear.
Device-config backups use an S3-compatible object store when configured, and fall back to local disk otherwise. For anything beyond a single appliance, configure it — set these on every app node / in the chart values:
BACKUP_S3_BUCKET=versus-backups
BACKUP_S3_ENDPOINT=https://minio.yourco.internal # omit for AWS S3
BACKUP_S3_REGION=us-east-1 # 'auto' for R2
BACKUP_S3_ACCESS_KEY_ID=...
BACKUP_S3_SECRET_ACCESS_KEY=...
# On AWS, prefer an IAM role instead of static keys:
BACKUP_S3_USE_IAM_ROLE=trueFor disaster recovery, add continuous Postgres backups (WAL archiving + base backups → point-in-time recovery), enable versioning on the bucket, and escrow theFIELD_ENCRYPTION_KEY. With off-site bucket replication and a warm standby cluster, this extends to multi-site failover.
On-prem installs enforce a signed .veslic license (VERSUS_DEPLOYMENT_MODE=onpremis set for you by the appliance/chart). After first boot, open System → Licensing and either apply the license file (offline) or enter an activation code (online). The install is node-locked to the fingerprint shown there; leaving the license server unset keeps the install fully air-gapped. See the Activating an On-Premise License guide for the full offline/online flow, metering, and states.
If you manage devices on segmented or isolated networks, enable the collector gateway(appliance: keep the gateway service; Helm: --set gateway.enabled=true) and enroll collectors against its address. Collectors are outbound-only (bearer-authenticated TLS on 443) — no inbound firewall exceptions into your network.
docker load / registry mirror. Nothing pulls from the internet..veslic file; leave the license server unset so the platform never phones home. Renewals are re-uploaded files.Once you're installed, staying current is a separate workflow — don't re-run this install guide to upgrade. In short: bump the image tag and the forward-only migrations run automatically (a pre-upgrade Job on Kubernetes; on boot for the appliance), after a database backup / VM snapshot.
The full upgrade, rollback, air-gap and software-currency procedures for every form factor — plus the supported-version policy and the security-release / vulnerability-remediation cadence — live in Lifecycle Management.
GET /api/updates/health/ should return OK once the DB is reachable and migrations have run.docker compose logs -f web; k8skubectl -n versus logs deploy/versus-web (and -worker, -gateway).The environment variables you'll most commonly set:
| Variable | Purpose |
|---|---|
DATABASE_URL | Postgres connection (postgres://user:pass@host:5432/versus) |
SECRET_KEY | Django secret key (required) |
FIELD_ENCRYPTION_KEY | Fernet key for encrypted columns (required — escrow it) |
ALLOWED_HOSTS | Comma-separated hostnames, or * on a trusted LAN |
VERSUS_DEPLOYMENT_MODE | onprem (set for you) — turns on license enforcement |
DISABLE_INPROC_SCHEDULERS | 1 on extra app nodes so pollers run on one node only |
RUN_SEEDS | 0 on nodes that should not run built-in seeds on boot |
PROCESS_ROLE | worker or gateway to run those roles instead of the web app |
BACKUP_S3_* | Object storage for durable device backups (§5) |
OIDC_ISSUER / OIDC_CLIENT_ID / OIDC_CLIENT_SECRET | Federate to your own OIDC IdP (Okta/Entra/Ping/etc.); else use local or LDAP under System → Roles |
VERSUS_LICENSE_SERVER | Online activation endpoint; empty = air-gapped (no phone-home) |
GUNICORN_WORKERS | Web worker processes per pod/container (default 4) |