Installing USCP on Kubernetes (Helm)
The Helm chart in deploy/helm/uscp/ deploys USCP with HA defaults: 3 replicas, a PodDisruptionBudget, an HPA (3→20 on 65% CPU), a hardened pod security context, and Prometheus scrape annotations. Works on any conformant cluster — EKS, AKS, GKE, OpenShift, Rancher, kubeadm.
Cloud-specific cluster/database/registry setup is in aws.md, azure.md, and gcp.md. This guide is the chart reference and the generic flow.
Read README.md first for OIDC and licensing prerequisites.
Architecture on Kubernetes
- Each pod runs the single
controlplanebinary (embedded UI + API + workers), listening on
:8080 (Service type ClusterIP, port 8080).
- Database is external — the chart does not deploy Postgres. Point it at a managed
Postgres 16 (RDS / Azure Database / Cloud SQL) or an in-cluster operator, via DATABASE_URL.
- All secrets come from a pre-created Secret named
uscp-secrets(envFrom.secretRef), so
nothing sensitive lives in values.yaml.
- Probes: readiness →
/readyz, liveness →/livez. The Service only receives traffic
once /readyz passes (DB + license healthy).
- Cross-replica live updates (SSE) work because replicas coordinate through Postgres
LISTEN/NOTIFY — no sticky sessions required.
Two presets
| Preset | File | Replicas | Licensing default | For |
|---|---|---|---|---|
| Clustered (default) | values.yaml | 3 (+HPA, PDB) | online | SaaS / HA |
| Single-instance | values-single.yaml | 1 (no HPA/PDB) | airgap | on-prem / small |
Same security, SSO+MFA, audit, and RLS posture in both — the single preset is not a stripped SKU.
Step 1 — Build and push the image
The chart's default image (ghcr.io/doublelogic/uscp/controlplane) is a placeholder. Build from the repo's Dockerfile (multi-stage: Go build → distroless nonroot, EXPOSE 8080) and push to your registry:
cd codes
docker build -t <registry>/uscp/controlplane:v1 .
docker push <registry>/uscp/controlplane:v1
(ECR/ACR/Artifact Registry specifics are in the per-cloud guides.)
Step 2 — Provision the database
A managed Postgres 16 with:
- A dedicated database (e.g.
uscp). - A non-superuser role (e.g.
uscp_app) — critical: USCP enforces per-tenant isolation
with FORCE ROW LEVEL SECURITY, and a superuser bypasses it. See post-install.md → Database hardening.
- Network reachability from the node/pod subnet; TLS (
sslmode=require).
Migrations run automatically at boot; no manual migration job is required.
Step 3 — Create the uscp-secrets Secret
Every key in this Secret is injected as an environment variable into the pods.
Online licensing (clustered default):
kubectl create namespace uscp
kubectl -n uscp create secret generic uscp-secrets \
--from-literal=DATABASE_URL='postgres://uscp_app:PASSWORD@db-host:5432/uscp?sslmode=require' \
--from-literal=USCP_OIDC_ISSUER='https://accounts.google.com' \
--from-literal=USCP_OIDC_CLIENT_ID='...' \
--from-literal=USCP_OIDC_CLIENT_SECRET='...' \
--from-literal=USCP_INSTALL_KEY='<your-install-key>'
(Online mode needs outbound HTTPS from pods to licensing.doublelogic.org.)
Air-gap licensing — see Air-gap on Kubernetes below; it requires mounting the signed bundle, which the base chart does not wire up automatically.
Step 4 — Install the chart
Clustered (HA):
helm install uscp deploy/helm/uscp -n uscp \
--set image.repository=<registry>/uscp/controlplane \
--set image.tag=v1 \
--set env.USCP_REGION=us-east-1 \
--set env.USCP_PUBLIC_URL=https://uscp.example.com
Single-instance (on-prem/small):
helm install uscp deploy/helm/uscp -n uscp -f deploy/helm/uscp/values-single.yaml \
--set image.repository=<registry>/uscp/controlplane \
--set image.tag=v1
Step 5 — Ingress, TLS, DNS
Zero-touch (recommended): let the chart create the Ingress
If your cluster runs external-dns and cert-manager, the chart creates the Ingress with the right annotations so the DNS record and the TLS certificate are both provisioned automatically — no manual DNS, no manual cert:
helm upgrade uscp deploy/helm/uscp -n uscp --reuse-values \
--set ingress.enabled=true \
--set ingress.host=uscp.example.com \
--set ingress.className=nginx \
--set ingress.clusterIssuer=letsencrypt-prod \
--set env.USCP_PUBLIC_URL=https://uscp.example.com
This renders an Ingress carrying cert-manager.io/cluster-issuer (→ auto TLS into <release>-tls) and external-dns.alpha.kubernetes.io/hostname (→ auto DNS record). Set ingress.externalDNS=false if you don't run external-dns, and add any controller-specific settings via ingress.annotations.
Manual: bring your own Ingress
Leave ingress.enabled=false (the default) and expose the ClusterIP Service (port 8080) yourself:
# example: NGINX ingress + cert-manager
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: uscp
namespace: uscp
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: [uscp.example.com]
secretName: uscp-tls
rules:
- host: uscp.example.com
http:
paths:
- path: /
pathType: Prefix
backend: { service: { name: uscp, port: { number: 8080 } } }
Either way, make sure the OIDC redirect URI is https://uscp.example.com/auth/callback and set env.USCP_PUBLIC_URL=https://uscp.example.com.
Step 6 — Verify
kubectl -n uscp get pods # all Running, READY 1/1
kubectl -n uscp rollout status deploy/uscp
# quick internal check without ingress:
kubectl -n uscp port-forward svc/uscp 8080:8080 &
curl -s localhost:8080/readyz
curl -s localhost:8080/.well-known/api-capabilities | jq .capability_count
# through the ingress:
curl -fsS https://uscp.example.com/readyz
Then continue at post-install.md → First login.
Chart values reference
| Key | Default | Notes |
|---|---|---|
replicaCount | 3 | Base replicas (HPA overrides when enabled). |
image.repository | ghcr.io/doublelogic/uscp/controlplane | Override with your registry. |
image.tag | "" → Chart.AppVersion | Pin to your pushed tag. |
service.type / service.port | ClusterIP / 8080 | |
ingress.enabled | false | Create an Ingress (zero-touch DNS+TLS with external-dns + cert-manager). |
ingress.host | "" | FQDN — required when ingress.enabled. |
ingress.className | "" | IngressClass (e.g. nginx). |
ingress.clusterIssuer | "" | cert-manager ClusterIssuer → auto TLS + tls block. |
ingress.externalDNS | true | Add the external-dns hostname annotation (auto DNS record). |
ingress.annotations | {} | Extra ingress annotations, merged. |
existingSecret | uscp-secrets | Secret providing DB/OIDC/license env. |
env.USCP_REGION | us-east-1 | Region label. |
env.LICENSING_MODE | online (clustered) / airgap (single) | |
env.USCP_LICENSE_ISSUER | licensing.doublelogic.org | |
env.USCP_PUBLIC_URL | (set this) | https://<domain> for correct links/redirects. |
resources.requests | 500m / 512Mi | |
resources.limits | 2 CPU / 1536Mi | Matches the perf budget. |
podDisruptionBudget.minAvailable | 2 | |
autoscaling | 3→20 @ 65% CPU | |
securityContext | nonRoot 65532, RO rootfs, no priv-esc, seccomp RuntimeDefault | |
monitoring.prometheusRule.enabled | false | Ship alert rules (needs the CRD). |
backup.enabled | false | CronJob pg_dump -Fc + retention + optional S3. |
Pods always carry prometheus.io/scrape annotations for /metrics — a Prometheus with pod-annotation discovery scrapes them with no extra config.
Automated backups
Enable the built-in backup CronJob:
helm upgrade uscp deploy/helm/uscp -n uscp --reuse-values \
--set backup.enabled=true \
--set backup.schedule='0 2 * * *' \
--set backup.retentionDays=14 \
--set backup.s3Uri='s3://my-bucket/uscp-backups' # optional; image must include awscli
It runs pg_dump -Fc against DATABASE_URL on the schedule, prunes beyond retentionDays, and optionally uploads to S3. Restore procedure and DR drill are in post-install.md → Backups.
Prometheus alert rules
helm upgrade uscp deploy/helm/uscp -n uscp --reuse-values \
--set monitoring.prometheusRule.enabled=true \
--set monitoring.prometheusRule.labels.release=kube-prometheus-stack
Requires the PrometheusRule CRD (kube-prometheus-stack). The label must match your Prometheus's rule selector.
Air-gap on Kubernetes
Two options, because the base chart injects license config via env but does not itself mount the signed bundle file:
- Bake the license into the image (simplest). Add the generated
dev-entitlement.licand
dev-jwks.json (see airgap.md) into your image at build time, and set the paths via the single-instance preset (USCP_AIRGAP_BUNDLE=/etc/uscp/entitlement.lic, USCP_LICENSE_JWKS=/etc/uscp/jwks.json).
- Mount them from a Secret by patching the Deployment to add a
volume/volumeMountfor
the two files at those paths (the chart has no extraVolumes value, so this is a kustomize overlay or a chart edit). Store the bundle+JWKS in a Secret and mount at /etc/uscp/.
For most clustered deployments with any outbound connectivity, online licensing is simpler — use it unless the cluster is genuinely offline.
Continue at post-install.md.