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.
- Manual (any hypervisor / bare metal) — provision a VM yourself
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, setUSCP_DB_MODE=externalat 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
- 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=autoand setUSCP_DOMAIN+USCP_ACME_EMAIL. - Behind an LB → set
USCP_TLS=none; USCP serves plain HTTP on:8080and your LB fronts it.
- Database — one of:
USCP_DB_MODE=docker(default) — Postgres 16 in a local Docker container on127.0.0.1:5432.USCP_DB_MODE=native— Postgres installed as a host package; installer creates theuscp
role and database.
USCP_DB_MODE=external— an existing Postgres cluster; passDATABASE_URL.
- License —
airgap(default, self-signed, no internet) oronline(needs
USCP_INSTALL_KEY and outbound HTTPS).
Step 1 — Provision the VM
| Resource | Minimum | Recommended |
|---|---|---|
| vCPU | 2 | 4 |
| RAM | 4 GB | 8 GB |
| Disk | 20 GB | 50 GB+ (Postgres grows) |
| OS | Ubuntu 22.04 LTS | Ubuntu 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/hostson 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
- Builds the static
controlplanebinary (or installsUSCP_BINARY). - Provisions the database per
USCP_DB_MODEand runs all migrations. - Generates a self-signed air-gap license (default) into
/etc/uscp/. - Writes
/etc/uscp/controlplane.env(0600, owned byuscp) with a generated
USCP_DATA_KEY.
- Installs and starts the
controlplane.servicesystemd unit (non-rootuscpuser,
ProtectSystem=strict).
- When
USCP_TLS=autoand a domain is set: writes/etc/caddy/Caddyfile, starts Caddy, gets a
Let's Encrypt cert.
- Opens 80/443 via
ufw/firewalld. - Verifies
/healthzand/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
:8080HTTP only; no Caddy, no ACME. - Configure your LB to forward
https://uscp.example.com→http://<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.