Status: 104 capabilities — 20 GA, 84 Beta. Enforcement is monitor-only by default. Honest status →

Installing USCP on an on-prem VM

For VMware vSphere, Proxmox, Hyper-V, Nutanix, or a bare-metal Ubuntu/RHEL host. This is the most common self-managed deployment and uses the one-command installer deploy/install.sh.

Read README.md first for DNS, OIDC, and licensing prerequisites. If your network has no internet access, follow airgap.md instead — it covers building the binary and license bundle offline.

Two ways to provision:

— if you run vCenter or Nutanix Prism, one terraform apply clones a template and installs unattended.

and run the one-command installer. Works everywhere (Hyper-V, Proxmox, KVM, bare metal).


Automated provisioning (VMware vSphere / Nutanix)

If your on-prem platform is VMware vSphere/vCenter or Nutanix AHV (Prism Central), use the Terraform modules — Terraform provisions the Ubuntu 24.04 image itself (no manual golden-image prep) and runs deploy/install.sh via cloud-init. Same install as the manual path, zero hand steps. For air-gapped vCenter/Prism, a toggle reuses an image you pre-staged.

VMware vSphere / vCenter

By default Terraform deploys the Ubuntu cloud OVA from the internet onto an ESXi host — no pre-built template. (Air-gap: create_from_ovf=false + a local vm_template.)

cd codes/deploy/terraform/vsphere
terraform init
terraform apply \
  -var="vsphere_server=vcenter.corp.local" \
  -var="vsphere_user=administrator@vsphere.local" -var="vsphere_password=..." \
  -var="datacenter=DC1" -var="cluster=Cluster1" -var="datastore=vsanDatastore" \
  -var="network=VM Network" -var="esxi_host=esxi01.corp.local" \
  -var="domain=uscp.corp.local" \
  -var="oidc_issuer=https://keycloak.corp.local/realms/uscp" \
  -var="oidc_client_id=uscp" -var="oidc_client_secret=..." \
  -var="acme_email=ops@corp.local"
# air-gap / static IP: -var="create_from_ovf=false" -var="vm_template=ubuntu-2404-cloudinit" \
#                      -var="ip_address=10.0.0.20" -var="ip_prefix=24" -var="gateway=10.0.0.1"

terraform output ip_address → create the DNS record, then verify https://<domain>/readyz.

Nutanix AHV (Prism Central)

By default Terraform creates the Ubuntu image in Prism (pulled from cloud-images.ubuntu.com) — no manual upload. (Air-gap: create_image=false + image_name of a pre-uploaded image.)

cd codes/deploy/terraform/nutanix
terraform init
terraform apply \
  -var="prism_endpoint=prismcentral.corp.local" \
  -var="prism_username=admin" -var="prism_password=..." \
  -var="cluster_name=AHV-Cluster-1" -var="subnet_name=vlan0-managed" \
  -var="domain=uscp.corp.local" \
  -var="oidc_issuer=https://keycloak.corp.local/realms/uscp" \
  -var="oidc_client_id=uscp" -var="oidc_client_secret=..." \
  -var="acme_email=ops@corp.local"
# air-gap: -var="create_image=false" -var="image_name=<your-uploaded-image>"

terraform output ip_address → create the DNS record, then verify https://<domain>/readyz.

Zero-touch internal DNS (optional). If your internal DNS accepts RFC 2136 dynamic updates (BIND, Windows DNS with secure updates, Infoblox…), add -var="dns_zone=corp.local" -var="dns_server=10.0.0.53" -var="dns_tsig_key_name=uscp-update." -var="dns_tsig_secret=<base64>" and Terraform creates the A record for you — no manual DNS step. (Both the vSphere and Nutanix modules support this.)

Internal-only name? Public Let's Encrypt can't validate a non-public FQDN — front USCP with an internal LB (USCP_TLS=none) or use an internal-CA cert (see below). For production Postgres, set USCP_DB_MODE=external at the installer level rather than the in-box container.

The rest of this guide (below) is the manual path for any other hypervisor or bare metal, and applies to the Terraform-provisioned VM too for post-install operations.


Decide three things first

  1. TLS termination — will USCP terminate TLS itself (Caddy + Let's Encrypt, needs the host

reachable on port 80/443 from the internet or an internal ACME), or sit behind an existing load balancer / reverse proxy that already does TLS?

  • Self-terminate → keep USCP_TLS=auto and set USCP_DOMAIN + USCP_ACME_EMAIL.
  • Behind an LB → set USCP_TLS=none; USCP serves plain HTTP on :8080 and your LB fronts it.
  1. Database — one of:
    • USCP_DB_MODE=docker (default) — Postgres 16 in a local Docker container on 127.0.0.1:5432.
    • USCP_DB_MODE=native — Postgres installed as a host package; installer creates the uscp

role and database.

  • USCP_DB_MODE=external — an existing Postgres cluster; pass DATABASE_URL.
  1. Licenseairgap (default, self-signed, no internet) or online (needs

USCP_INSTALL_KEY and outbound HTTPS).


Step 1 — Provision the VM

Resource Minimum Recommended
vCPU24
RAM4 GB8 GB
Disk20 GB50 GB+ (Postgres grows)
OSUbuntu 22.04 LTSUbuntu 22.04 / RHEL 9

Give it a static IP and a resolvable hostname. Ensure outbound HTTPS if you use online licensing or build from source (to fetch Go modules); otherwise see airgap.md.

Step 2 — DNS

Create an A record for your chosen name pointing at the VM:

  • Internet-facing: uscp.example.com → <public IP> (required for Let's Encrypt).
  • Internal-only: an internal DNS name (or /etc/hosts on clients). Public ACME won't work

for a non-public name — either use USCP_TLS=none behind an internal LB that holds your corporate certificate, or provide your own cert to Caddy (see Behind a load balancer).

Confirm: dig +short uscp.example.com.

Step 3 — Install prerequisites

# Ubuntu/Debian
sudo apt-get update && sudo apt-get install -y git curl
# For USCP_DB_MODE=docker:
sudo apt-get install -y docker.io && sudo systemctl enable --now docker

Go is installed automatically by the installer when building from source. To skip the build, supply a prebuilt binary with USCP_BINARY=/path/to/controlplane.

Step 4 — Get the code

git clone https://github.com/cfssay/doublelogic_apex uscp
cd uscp/codes

Step 5 — Run the installer

Option A — Self-terminating TLS, Docker database (simplest)

sudo \
  USCP_DOMAIN=uscp.example.com \
  USCP_OIDC_ISSUER=https://accounts.google.com \
  USCP_OIDC_CLIENT_ID=<client-id> \
  USCP_OIDC_CLIENT_SECRET=<client-secret> \
  USCP_ACME_EMAIL=ops@example.com \
  ./deploy/install.sh

Option B — Native host Postgres

sudo \
  USCP_DOMAIN=uscp.example.com \
  USCP_OIDC_ISSUER=https://accounts.google.com \
  USCP_OIDC_CLIENT_ID=<client-id> USCP_OIDC_CLIENT_SECRET=<client-secret> \
  USCP_ACME_EMAIL=ops@example.com \
  USCP_DB_MODE=native \
  ./deploy/install.sh

The installer installs the postgresql package, initializes it, and creates the uscp role and database.

Option C — External Postgres cluster

sudo \
  USCP_DOMAIN=uscp.example.com \
  USCP_OIDC_ISSUER=https://accounts.google.com \
  USCP_OIDC_CLIENT_ID=<client-id> USCP_OIDC_CLIENT_SECRET=<client-secret> \
  USCP_ACME_EMAIL=ops@example.com \
  USCP_DB_MODE=external \
  DATABASE_URL='postgres://uscp_app:PASSWORD@db.internal:5432/uscp?sslmode=require' \
  ./deploy/install.sh

Use a non-superuser role for DATABASE_URL — a superuser bypasses PostgreSQL row-level security and breaks per-tenant isolation. See post-install.md → Database hardening.

What the installer does

  1. Builds the static controlplane binary (or installs USCP_BINARY).
  2. Provisions the database per USCP_DB_MODE and runs all migrations.
  3. Generates a self-signed air-gap license (default) into /etc/uscp/.
  4. Writes /etc/uscp/controlplane.env (0600, owned by uscp) with a generated

USCP_DATA_KEY.

  1. Installs and starts the controlplane.service systemd unit (non-root uscp user,

ProtectSystem=strict).

  1. When USCP_TLS=auto and a domain is set: writes /etc/caddy/Caddyfile, starts Caddy, gets a

Let's Encrypt cert.

  1. Opens 80/443 via ufw/firewalld.
  2. Verifies /healthz and /readyz.

Step 6 — Verify

sudo systemctl status controlplane        # active (running)
curl -fsS https://uscp.example.com/healthz # 200 ok
curl -fsS https://uscp.example.com/readyz  # 200 once DB + license ready

# logs, if anything is off:
journalctl -u controlplane -n 100 --no-pager

Then continue at post-install.md → First login.


Behind a load balancer or internal CA

If an existing appliance (F5, NGINX, HAProxy, corporate reverse proxy) terminates TLS, run USCP in plain-HTTP mode and let the LB front it:

sudo \
  USCP_DOMAIN=uscp.example.com \
  USCP_TLS=none \
  USCP_OIDC_ISSUER=https://accounts.google.com \
  USCP_OIDC_CLIENT_ID=<client-id> USCP_OIDC_CLIENT_SECRET=<client-secret> \
  ./deploy/install.sh
  • USCP now listens on :8080 HTTP only; no Caddy, no ACME.
  • Configure your LB to forward https://uscp.example.comhttp://<vm-ip>:8080, preserving

Host and setting X-Forwarded-Proto: https.

  • The OIDC redirect URI at your IdP is still https://uscp.example.com/auth/callback (the

public name), and set USCP_PUBLIC_URL=https://uscp.example.com so generated links are correct — add it to /etc/uscp/controlplane.env and sudo systemctl restart controlplane.

  • Firewall: allow 8080 only from the LB, not from the whole network.

For an internal CA with Caddy self-terminating, replace the Let's Encrypt directive in /etc/caddy/Caddyfile with a tls /path/cert.pem /path/key.pem line pointing at your internally-issued certificate, then sudo systemctl restart caddy.


VMware / Proxmox notes

  • Template it: build one VM to the "Step 5 done" state, then clone. On each clone, before

first boot rotate /etc/uscp/controlplane.env's USCP_DATA_KEY only if the clone is a fresh install — never share a data key across independent installs that hold different data. For a true golden image, install with USCP_BINARY and let each clone run the DB/license steps on first boot.

  • Snapshots are not backups: take logical Postgres backups with the provided

deploy/backup/pg-backup.sh. See post-install.md → Backups.

  • Time sync: ensure the guest clock is synced (VMware Tools / chrony). Token and license

validation are time-sensitive.

  • Resize: to grow, stop controlplane, expand the disk/RAM in the hypervisor, grow the

filesystem, and start again — the binary is stateless; all state is in Postgres.

Continue at post-install.md.