Getting Started

On-Premise Installation Guide

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.

NoteInstalling fresh. This guide stands up a new Versus deployment. Already running Versus and moving to a newer build? See Lifecycle Management for upgrade, rollback and software-currency procedures.

1. Choose a form factor

All three run the identical image and honor the identical signed .veslic license — pick by need, not by product.

Form factorBest forDatabaseHA
A · Single-node applianceTrials, POCs, small/medium prod, air-gappedBundled in the VMSnapshot + backup
B · Kubernetes (Helm)Large-scale HA, teams already on k8sExternal / operator PostgresAutomatic (multi-replica, failover)
C · Clustered appliances + LBHA / redundancy without KubernetesOne shared external PostgresApp: auto via LB · DB: manual (or auto w/ HA Postgres)
NoteNo Kubernetes environment? You're still covered — Option A (a single VM, non-HA) or Option C (two or more appliances behind your own load balancer = HA without Kubernetes). A turnkey embedded-Kubernetes appliance — a VM image with a lightweight k3s and the Helm chart pre-installed, giving full automatic HA without operating Kubernetes yourself — is an optional convenience on the roadmap, not a prerequisite.

2. Common prerequisites

Whichever option you choose, have these ready:

  • Sizing (starting point): 4 vCPU / 8 GB RAM / 40 GB disk for a single node; scale up with estate size. Kubernetes: a 3+ node cluster.
  • Two secrets the app requires — a Django SECRET_KEY and aFIELD_ENCRYPTION_KEY (a Fernet key that encrypts sensitive columns). Escrow the Fernet key — losing it makes encrypted data unrecoverable.
  • TLS on the app server. The app is reached only through a TLS-terminating proxy on the same host / pod; its own HTTP listener is bound to the local host and isnever exposed on the network. See the encryption note below.
  • DNS — a hostname for the UI (e.g. versus.yourco.internal).
  • Your .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())')

Pulling secrets from an external vault (optional)

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:

  • Kubernetes: use the External Secrets Operator or the HashiCorpVault Agent Injector (or the Secrets Store CSI driver) to sync from HashiCorp Vault / AWS Secrets Manager / Azure Key Vault into a Kubernetes Secret, then point the chart at it withsecrets.existingSecret=<name> — nothing sensitive lives in values.yaml.
  • Appliance / VM: use Vault Agent or 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.)

Encryption in transit — terminate TLS on the app server

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:

  • Appliance & clustered nodes: run a TLS-terminating reverse proxy (Caddy / nginx) co-located on the same host as the app — it listens on 443 with your certificate and proxies to the app over the local loopback interface, so the app is never published on the network. The load balancer then speaks HTTPS to each node, so every hop on the wire is encrypted end to end.
  • Kubernetes: put a TLS sidecar (Caddy / nginx / Envoy) in the web pod that terminates HTTPS and proxies to the app over the pod's loopback — the app never leaves the pod in cleartext, and the ingress re-encrypts to the pod. Equivalently, a service mesh with mTLS (Istio, Linkerd) encrypts all pod-to-pod traffic transparently.
  • Datastores & dependencies: require TLS there too — Postgres viaDATABASE_URL=…?sslmode=verify-full, an HTTPS BACKUP_S3_ENDPOINT(MinIO / S3 over TLS), and an https:// OIDC_ISSUER for identity.
  • Collectors already use bearer-authenticated TLS on 443 end to end.
CautionNever expose the app's plain HTTP listener on the network. The appliance ships with a co-located Caddy that terminates TLS on the host and keeps the app internal — encrypted on the wire by default (self-signed cert; drop in your own to remove browser warnings). For a custom stack or the Kubernetes / clustered options, apply the same principle: co-locate TLS termination and keep the app off the network.

Option A — Single-node appliance

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.

A1 · Prebuilt VM image (OVA / qcow2)

1Import the image

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.

2Boot

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.

3Open the UI + apply your license

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.

A2 · Docker Compose (any Linux host)

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 + startup

The 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.

NoteBackups. By default device-config backups and the database live on the appliance's own disk — protected by VM snapshots. For durable, off-box backups, point BACKUP_S3_* at an S3 / MinIO endpoint (see §5) and schedule Postgres dumps off the VM.

Option B — Kubernetes (Helm)

The production HA form factor: multiple web replicas, a single background worker, scheduledCronJobs, an optional collector gateway, and automatic failover. Same image, same license.

B1 · Prerequisites

  • • A Kubernetes cluster 1.25+ and Helm 3.
  • • The Versus image reachable by the cluster — push versus/web:<tag> to a registry the cluster can pull (or load it into an air-gapped registry).
  • • An external / managed Postgres (RDS, Cloud SQL, or a Postgres operator such as CloudNativePG). For durable backups, an S3/MinIO endpoint.

B2 · Install

# 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 collectors

Prefer 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.

B3 · What the chart deploys

  • migrate Job (pre-install/upgrade hook) — runs DB migrations before pods roll, so multi-replica startup is race-free; a seed Job runs the built-in templates once.
  • web Deployment + Service (+ optional Ingress) — N replicas, with a PodDisruptionBudget and a DB-checking readiness probe. Web pods run with the in-process schedulers disabled.
  • worker Deployment (single replica) — owns the pollers so they fire exactly once.
  • scheduler CronJobs — backups, rotation, license heartbeat, checks; each withconcurrencyPolicy: Forbid so the non-idempotent ones never overlap.
  • alert-scheduler Deployment — the Observe alert-evaluation loop.
  • gateway Deployment + Service (optional) — the collector control plane.
  • ConfigMap + Secret — unless you bring your own Secret.

B4 · HA data tier (recommended)

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.

B5 · Reach the UI + license

# 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 -> Licensing

Validate the chart before installing

helm 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 | less

Option C — Clustered appliances behind a load balancer

Want 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.

C1 · Externalize the database

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/versus

To 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.

CautionA standby is read-only — the app can only write to the primary. So every app node points at the one primary; the standby exists purely for failover (promote + repoint on primary loss).

C2 · Pin the singletons & share state (the gotchas)

A few things are per-process and must be handled so the extra nodes don't double-fire or diverge:

ConcernDo this
Schedulers / pollersRun 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 tokensEnable sticky sessions on the LB so a client stays on one node.
Device backupsPoint BACKUP_S3_* at a shared S3/MinIO on every node (§5).
Media / PCAP / firmwarePut the media path on shared storage (NFS) mounted by all nodes.
MigrationsRun migrations from one node; set RUN_SEEDS=0 on the others.

C3 · Load balancer

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.

NoteOnce you've externalized the DB, added shared object storage, and singled-out the scheduler, you've done most of the work Kubernetes automates — Option B then gives you automatic failover and rolling upgrades on top, rather than manual ones. Reach for it when the manual failover here isn't enough.

4. Identity & authentication

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.

  • Local (default). Built-in username/password with TOTP MFA. No external system and no internet dependency — ideal for air-gapped and small sites. Always on.
  • AD / LDAP. Bind against your Active Directory or LDAP directory (LDAPS supported). Map directory groups to Versus roles under the same screen.
  • SAML / SSO and external OIDC. Federate to your own identity provider (Okta, Entra ID, Ping, Keycloak, etc.). For OIDC, point Versus at your IdP withOIDC_ISSUER, OIDC_CLIENT_ID and OIDC_CLIENT_SECRET; group mappings resolve on each login.
  • TACACS+. Available for network-operations shops that centralize auth there.

Whichever backends you enable, sign-in happens on the app server over HTTPS — credentials and OIDC redirects never traverse the network in the clear.

NoteOn-prem does not ship or require Versus's hosted identity provider — that runs only in the multi-tenant SaaS. On-prem authentication is entirely the built-in backends above, configured in System → Roles.

5. Object storage & backups

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=true

For 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.


6. Licensing

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.


7. Collectors (optional)

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.


8. Air-gapped installs

  • Images: load the Versus image (and Postgres/object store/ingress) into your private registry — docker load / registry mirror. Nothing pulls from the internet.
  • TLS: use your internal CA (no ACME).
  • Licensing: apply a node-locked .veslic file; leave the license server unset so the platform never phones home. Renewals are re-uploaded files.
  • Identity: use the built-in local backend (or an in-network LDAP/AD), so sign-in has no internet dependency. Backups stay in-network (in-cluster object store). AI features degrade gracefully without an API key.

9. Upgrades

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.


10. Verify & troubleshoot

  • Health: GET /api/updates/health/ should return OK once the DB is reachable and migrations have run.
  • Licensing state shows on System → Licensing (Active / Grace / Expired). If unlicensed/expired past grace, the system is read-only until a valid license is applied.
  • Logs: appliance docker compose logs -f web; k8skubectl -n versus logs deploy/versus-web (and -worker, -gateway).
  • Migrations racing on multi-node boot → ensure the migrate Job ran (k8s) or that only one node runs migrations (Option C).

11. Configuration reference

The environment variables you'll most commonly set:

VariablePurpose
DATABASE_URLPostgres connection (postgres://user:pass@host:5432/versus)
SECRET_KEYDjango secret key (required)
FIELD_ENCRYPTION_KEYFernet key for encrypted columns (required — escrow it)
ALLOWED_HOSTSComma-separated hostnames, or * on a trusted LAN
VERSUS_DEPLOYMENT_MODEonprem (set for you) — turns on license enforcement
DISABLE_INPROC_SCHEDULERS1 on extra app nodes so pollers run on one node only
RUN_SEEDS0 on nodes that should not run built-in seeds on boot
PROCESS_ROLEworker 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_SECRETFederate to your own OIDC IdP (Okta/Entra/Ping/etc.); else use local or LDAP under System → Roles
VERSUS_LICENSE_SERVEROnline activation endpoint; empty = air-gapped (no phone-home)
GUNICORN_WORKERSWeb worker processes per pod/container (default 4)