USCP Installation Guide
Unified Security Control Plane (USCP) — complete, step-by-step installation for every supported environment. Each guide is self-contained: follow one top to bottom and you get a production-grade deployment with SSO, TLS, per-tenant isolation, and a verified license.
These guides describe the real deploy tooling in this repository (
deploy/install.sh,deploy/terraform/,deploy/helm/uscp/,deploy/cloud-init.yaml,deploy/appliance/). Every command is copy-paste accurate against the code as shipped.
Start here
go-live-runbook.md is the single, repeatable path from package to production sign-off (decisions → pre-flight → deploy → automated acceptance → first login → sign-off → day-2). Every deployment should follow it. After any deploy, run the acceptance suite:
deploy/verify-deployment.sh https://<your-host>
It must print "DEPLOYMENT ACCEPTED" (0 failures) — it checks health, licensing-active, the served console, and that every licensed route rejects anonymous callers. The one-command installer runs it for you automatically.
Want to dry-run the entire deployment path first (no host changes, all in Docker)? Run the pilot:
make pilot # or: codes/deploy/pilot-local.sh
It builds the binary, boots it against a throwaway Postgres with a host-bound license, runs the acceptance suite + a perf probe, and tears everything down — proving the path end-to-end. See validation-evidence.md for what has been verified and how to reproduce it.
Deploying to a real cloud/on-prem VM and want it validated in one command? After setting your terraform.tfvars:
deploy/pilot-cloud.sh aws # aws | azure | gcp | vsphere | nutanix
It runs terraform apply (whose cloud-init runs the installer + acceptance suite), waits for readiness, then runs the acceptance suite against the live endpoint and prints the verdict.
Pick your guide
| Environment | Guide | Best for | Effort |
|---|---|---|---|
| AWS | aws.md | EC2 single-node, fully-automated Terraform, or EKS | Low–Medium |
| Azure | azure.md | Azure VM or AKS, with Azure Database for PostgreSQL | Low–Medium |
| GCP | gcp.md | Compute Engine VM or GKE, with Cloud SQL | Low–Medium |
| On-prem VM | onprem-vm.md | VMware/Proxmox/bare Ubuntu, self-managed Postgres | Low |
| Kubernetes | kubernetes.md | Any conformant cluster via the Helm chart | Medium |
| Air-gapped | airgap.md | No-internet networks, self-signed license bundle | Medium |
| After install | post-install.md | First login, hardening, backups, upgrades — read this for every install | — |
Not sure? For a single trusted admin box in any cloud or on-prem, use the one-command installer (deploy/install.sh) — the on-prem and AWS EC2 guides both drive it. For HA across 3+ replicas with an external database, use Kubernetes.
Just run it.
sudo ./deploy/install.shwith no environment variables on an interactive terminal launches a short guided setup (domain, OIDC, admin email) — then builds, migrates, gets TLS, and verifies, with no manual SQL and no post-install role grant. Automated paths (Terraform, cloud-init, CI) passUSCP_*vars and skip the prompts entirely.
Prefer Infrastructure-as-Code? Terraform modules provision the VM and run the installer in one terraform apply for AWS, Azure, GCP, VMware vSphere/vCenter, and Nutanix AHV — see deploy/terraform/ (one module per platform, all sharing a single portable cloud-init template). The AWS, Azure, GCP, and on-prem guides each lead with their Terraform path.
What gets deployed
USCP ships as one statically-linked Go binary (controlplane) with the React admin console embedded into it (go:embed) — there is no separate frontend to host. It listens on :8080 and speaks HTTP; a TLS terminator (Caddy on VMs, an ingress/load balancer on Kubernetes) fronts it on 443.
443/HTTPS :8080/HTTP 5432
Browser ──────────▶ Caddy / Ingress ──────────▶ controlplane ──────────▶ PostgreSQL 16
(SSO login) (Let's Encrypt TLS) (embedded UI + (per-tenant RLS)
REST API + workers)
Runtime components created by the installer on a VM:
/usr/local/bin/controlplane— the binary.systemdunitcontrolplane.service— runs as the non-rootuscpuser with
ProtectSystem=strict.
- Caddy (
/etc/caddy/Caddyfile) — reverse-proxieslocalhost:8080, obtains and renews a
Let's Encrypt certificate automatically.
- PostgreSQL 16 — as a local Docker container (default), a native package, or an external
managed database (RDS / Azure Database / Cloud SQL).
/etc/uscp/controlplane.env— the environment file (mode0600, owned byuscp) holding
all secrets. This is the only place secrets live on disk.
Prerequisites common to every environment
Do these once regardless of which guide you follow.
1. A DNS name
Choose a hostname, e.g. uscp.example.com. You will point an A record (or AAAA) at the server's public IP. TLS issuance and the OIDC redirect both depend on this name resolving publicly, so create the record early — DNS propagation can take minutes to hours.
Air-gapped installs use an internal DNS name (or
/etc/hosts) and skip public ACME — see airgap.md.
2. (Optional) An OIDC/SAML identity provider (SSO)
SSO is optional — the default login is local credentials (the installer creates the admin account for you). Configure SSO only if you want corporate single sign-on in addition to, or instead of, local login. To use SSO, register an application (a.k.a. "client"/"app registration") with your IdP and collect:
- Issuer URL — e.g.
https://accounts.google.com,https://login.microsoftonline.com/<tenant-id>/v2.0,
or your Okta/Keycloak/Auth0 issuer. This is USCP_OIDC_ISSUER.
- Client ID →
USCP_OIDC_CLIENT_ID - Client secret →
USCP_OIDC_CLIENT_SECRET - Redirect URI — set it exactly to:
https://<your-domain>/auth/callback
(For uscp.example.com that is https://uscp.example.com/auth/callback.) A mismatch here is the #1 cause of a failed first login.
Per-IdP walkthroughs (Google, Microsoft Entra ID, Okta, Keycloak) are in post-install.md → OIDC provider setup.
3. A license
USCP verifies a signed entitlement before it will serve. Two modes:
airgap(default) — a self-signed bundle you generate locally with the bundled
devlicgen tool. No internet, no external service. Best for evaluation, on-prem, and air-gapped installs. The installer generates one automatically if you don't supply one.
online— the binary calls the DoubleLogic licensing service at boot using an install
key (USCP_INSTALL_KEY). Requires outbound HTTPS to licensing.doublelogic.org.
Every guide defaults to airgap so you can stand the platform up with zero external dependencies. Switch to online only if you have an install key.
4. A machine that can build (or a prebuilt binary)
The installer builds the binary from a source checkout using Go 1.26.4 (installed automatically by the cloud-init path). Alternatively, supply a prebuilt binary with USCP_BINARY=/path/to/controlplane to skip the build entirely — required for air-gapped builds.
Minimum server sizing: 2 vCPU / 4 GB RAM / 20 GB disk (AWS t3.medium, Azure Standard_B2s, GCP e2-medium). Postgres-in-Docker adds ~1 GB RAM; use an external database for production.
Environment-variable reference
Two distinct sets. The installer variables (deploy/install.sh) configure the build and provisioning; the installer translates them into the runtime variables the binary reads from /etc/uscp/controlplane.env. On Kubernetes you set the runtime variables directly.
Installer variables (deploy/install.sh)
| Variable | Default | Purpose | ||
|---|---|---|---|---|
USCP_DOMAIN | (empty) | Public hostname. Empty ⇒ localhost dev mode, no TLS. | ||
USCP_OIDC_ISSUER | https://accounts.google.com | OIDC issuer URL. | ||
USCP_OIDC_CLIENT_ID | (empty) | OIDC client ID. | ||
USCP_OIDC_CLIENT_SECRET | (empty) | OIDC client secret. | ||
USCP_ACME_EMAIL | (empty) | Let's Encrypt account email (set this for TLS). | ||
USCP_ADMIN_EMAIL | (= ACME email) | Admin's email — the default passwordless login emails a one-time code here; also auto-granted admin. | ||
USCP_LOCAL_ADMIN_USER | admin | Local admin username (used for the authenticator method). Empty ⇒ SSO-only. | ||
USCP_LOGIN_METHOD | (auto) | email \ | totp \ | sso — the installer sets this from what works (SMTP verified ⇒ email). |
USCP_SMTP_HOST (+ _FROM/_USERNAME/_PASSWORD) | (empty) | SMTP relay — required for the default email one-time-code login; verified at install. | ||
USCP_LOCAL_ADMIN_TOTP_SECRET | (generated) | Authenticator secret (printed as an otpauth:// URI) when the method is totp. | ||
USCP_REGION | prod | Region label shown in the capability matrix. | ||
USCP_TENANT_SLUG | prod | Bootstrap tenant that SSO users land in. | ||
USCP_RP_DISPLAY_NAME | Unified Security Control Plane | WebAuthn / UI display name. | ||
LICENSING_MODE | airgap | airgap (self-signed) or online. | ||
USCP_INSTALL_KEY | (empty) | Bootstrap credential — required when online. | ||
USCP_DB_MODE | docker | docker (local container), native (host package), or external. | ||
DATABASE_URL | (empty) | Connection string — required when USCP_DB_MODE=external. | ||
USCP_TLS | auto | auto (Caddy + Let's Encrypt when domain set) or none. | ||
USCP_BINARY | (empty) | Path to a prebuilt binary — skips the Go build. | ||
USCP_LICENSE_BUNDLE | (empty) | Pre-made air-gap bundle path — skips generation. | ||
USCP_LICENSE_JWKS_IN | (empty) | Trusted JWKS path that pairs with the bundle. | ||
USCP_DATA_KEY | (auto-generated) | Data-encryption key; generated into the env file if unset. |
Runtime variables (read by the binary / used on Kubernetes)
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL | (required) | Postgres DSN. The binary refuses to boot without it. |
USCP_HTTP_ADDR | :8080 | Listen address. |
USCP_PUBLIC_URL | http://localhost:8080 | External base URL (https://<domain>). |
USCP_REGION | default | Region label. |
USCP_OIDC_ISSUER / _CLIENT_ID / _CLIENT_SECRET | (empty) | SSO config. |
USCP_OIDC_REDIRECT_URL | (derived) | Override the https://<domain>/auth/callback redirect. |
USCP_RP_ID | localhost | WebAuthn relying-party ID (set to your domain). |
LICENSING_MODE | airgap | airgap or online. |
USCP_AIRGAP_BUNDLE | (empty) | Path to the signed entitlement bundle (airgap). |
USCP_LICENSE_JWKS | (empty) | Path to the trusted JWKS. |
USCP_LICENSE_ISSUER | licensing.doublelogic.org | Expected license issuer. |
USCP_INSTALL_KEY | (empty) | Online-mode bootstrap credential. |
USCP_HOST_FINGERPRINT | (derived) | Overrides the derived single-deployment host fingerprint (§36). Set (and protect) on containers/K8s where machine identity is ephemeral, or a node-locked HA pair. Leave unset on bare-metal/VM for the strongest hardware lock. Get this host's value with controlplane fingerprint. |
USCP_REQUIRE_HOST_BINDING | false | true refuses any portable (unbound) license — only a license locked to this host's fingerprint runs. Recommended for production single-deployment installs. |
USCP_SHUTDOWN_DRAIN_SECONDS | 5 | Day-2 zero-downtime: seconds to keep serving after SIGTERM (readiness fails first) so load balancers deregister before in-flight drain. |
USCP_EVIDENCE_DIR | evidence | Directory for boot evidence + the host-bound anti-clock-rollback state file (§36). |
USCP_DEV_TENANT_SLUG | (empty) | Auto-provision this tenant on boot (dev/eval only). |
USCP_BOOTSTRAP_ADMIN | (empty) | Email granted admin on its first SSO login into the bootstrap tenant. |
Optional tuning (validated at boot; a typo fails fast): USCP_SESSION_IDLE_MINUTES, USCP_SESSION_MAX_HOURS, USCP_RATELIMIT_RPS, USCP_RATELIMIT_BURST, USCP_MAX_BODY_BYTES, USCP_SMTP_HOST + USCP_SMTP_FROM, USCP_BYO_LAKE_URL.
The 60-second mental model of an install
- DNS
A uscp.example.com → <server IP>. - OIDC app with redirect
https://uscp.example.com/auth/callback. - Run
deploy/install.sh(orhelm install) with your domain, OIDC creds, and ACME email. - Installer builds the binary, starts Postgres, runs all migrations, **generates a
license, writes /etc/uscp/controlplane.env, creates the systemd unit and Caddy config, opens the firewall, and verifies /healthz + /readyz**.
- Browse to
https://uscp.example.com, log in with SSO — the email you set as
USCP_ADMIN_EMAIL (or your ACME email) is admin automatically on first login. Provision your break-glass code. Done. No SQL, no manual role grant.
Now open the guide for your environment.
Verifying any install
Regardless of environment, a healthy deployment answers:
curl -fsS https://<your-domain>/healthz # liveness → 200 {"status":"ok"}
curl -fsS https://<your-domain>/readyz # readiness → 200 once DB + license are good
/readyz returning non-200 means the binary is up but a dependency (database or license) is not ready — check journalctl -u controlplane (VM) or kubectl logs (Kubernetes). Full troubleshooting matrix in post-install.md.