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

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 controlplane binary (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.yaml3 (+HPA, PDB)onlineSaaS / HA
Single-instancevalues-single.yaml1 (no HPA/PDB)airgapon-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

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
replicaCount3Base replicas (HPA overrides when enabled).
image.repositoryghcr.io/doublelogic/uscp/controlplaneOverride with your registry.
image.tag""Chart.AppVersionPin to your pushed tag.
service.type / service.portClusterIP / 8080
ingress.enabledfalseCreate 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.externalDNStrueAdd the external-dns hostname annotation (auto DNS record).
ingress.annotations{}Extra ingress annotations, merged.
existingSecretuscp-secretsSecret providing DB/OIDC/license env.
env.USCP_REGIONus-east-1Region label.
env.LICENSING_MODEonline (clustered) / airgap (single)
env.USCP_LICENSE_ISSUERlicensing.doublelogic.org
env.USCP_PUBLIC_URL(set this)https://<domain> for correct links/redirects.
resources.requests500m / 512Mi
resources.limits2 CPU / 1536MiMatches the perf budget.
podDisruptionBudget.minAvailable2
autoscaling3→20 @ 65% CPU
securityContextnonRoot 65532, RO rootfs, no priv-esc, seccomp RuntimeDefault
monitoring.prometheusRule.enabledfalseShip alert rules (needs the CRD).
backup.enabledfalseCronJob 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:

  1. Bake the license into the image (simplest). Add the generated dev-entitlement.lic and

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

  1. Mount them from a Secret by patching the Deployment to add a volume/volumeMount for

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.