CM Platform Deployment Guide
A deployment guide for CM Platform on Kubernetes using Helm and ArgoCD.
Overview
CM Platform Installer automates the deployment of Confidential Mind's platform on Kubernetes clusters using ArgoCD and Helm. It simplifies the deployment process through a Helm chart that sets up all required components with minimal configuration.
Installing into an environment without internet access? Read this guide first, then follow the deviations in Air-Gapped Installation.
Prerequisites
CM Platform deployment requires:
1. Kubernetes Cluster
- A running Kubernetes cluster
- A valid kubeconfig file with admin permissions
- A CSI driver for persistent storage, optionally Longhorn CSI (if using Longhorn see prerequisites below)
- amd64 nodes running model-serving components must support x86-64-v3 (AVX2-generation CPUs, roughly 2015 or newer); arm64 nodes have no such floor
For OpenShift/OKD: See OpenShift/OKD Prerequisites for additional node-level configuration required before deployment.
2. CM Repositories Access
- CM Platform GitOps Repository Access (explained in "Prepare Required Keys and Credentials" \ "CM Git Repository Access" )
- CM OCI Registry Access (explained in "Prepare Required Keys and Credentials" \ "CM OCI Registry Access" )
3. DNS Configuration
A wildcard DNS record pointing to your cluster's ingress IP or hostname:
| Record type | When to use | Example |
|---|---|---|
A | Ingress IP is known | *.<base>.<domain>.<com> → <K8s_ingress_public_IP> |
CNAME | LB provides a hostname rather than a bare IP | *.<base>.<domain>.<com> CNAME <lb-hostname> |
A wildcard DNS record (*.base.domain.com) resolves all subdomains under the base domain to the same IP, enabling dynamic service deployment without individual DNS entries per service. This is essential for microservices platforms.
4. TLS Planning
Load balancer TLS handling: Before choosing a certificate source, verify how your load balancer handles port 443 — passthrough (Istio manages TLS end-to-end) vs. TLS termination (LB decrypts and re-encrypts). This affects which option works correctly in your environment. See Troubleshooting for details.
CM Platform supports four certificate sources:
| Option | Description | Requires |
|---|---|---|
selfsigned (default) | Auto-generated self-signed certificate | Nothing — works out of the box |
letsencrypt | Automatically issued by Let's Encrypt via ACME | Valid public DNS + reachable HTTP endpoint on port 80 |
own | Bring your own wildcard certificate | PEM files placed in own-cert/ directory |
customTlsSecret | Serve a TLS certificate that you create and renew yourself | An existing kubernetes.io/tls Secret in the istio-ingress namespace |
See TLS Settings for the values file snippets for each option.
For
selfsigned,own, andcustomTlsSecretcertificates, LB-level TLS re-encryption is compatible — the platform runs no ACME validation in these modes. In these cases a CNAME DNS record to the LB hostname also works — see DNS Configuration above. One caveat forcustomTlsSecret: if the issuer you configure uses an ACMEhttp01solver, it inherits the same port 80 requirement asletsencrypt.
5. Required Tools
- kubectl
- helm
Installation Process
1. Prepare Directory Structure
export clustername=<your-clustername--or--projectname>
mkdir cm-platform-$clustername
cd cm-platform-$clustername
mkdir git-access-keys
mkdir image-registries-auth
2. Prepare Required Keys and Credentials
- CM Git Repository Access:
- Generate SSH Key
ssh-keygen -t ed25519 -f git-access-keys/cm-platform-gitops -N ""
Leave the passphrase empty — passphrase-protected keys are not supported by the CM Platform installer.
- Share the public key (
cm-platform-gitops.pub) with CM team
- CM OCI Registry Access:
- Obtain
cm-images.jsonfrom CM team - Save it as
image-registries-auth/cm-images.json
3. Custom TLS Certificates (Optional)
If using your own TLS certificates:
- Create
own-certdirectorymkdir own-cert - Place your certificate files:
cm-platform-$clustername/own-cert/fullchain.pem(full certificate chain)cm-platform-$clustername/own-cert/privkey.pem(private key)
The certificate must be a wildcard certificate (e.g. *.cm-platform-prod.example.com) valid for any subdomain under the base domain.
4. Prepare Configuration Files
Basic setup profile
Download and modify the examples values file. The example has a description for each parameter.
curl -o basic-values.yaml https://raw.githubusercontent.com/ConfidentialMind/examples/main/platform-deployment/custom-values-profiles/basic-values.yaml
(advanced) For more configuration parameters check the Optional Settings section in the bottom:
Other setup profiles
OpenShift/OKD:
curl -o openshift-values.yaml https://raw.githubusercontent.com/ConfidentialMind/examples/main/platform-deployment/custom-values-profiles/openshift-values.yaml
5. Verify Directory Structure
tree .
Expected minimal structure:
cm-platform-<your-clustername--or--projectname>/
├── basic-values.yaml # Your basic custom settings
├── ***-values.yaml # (Optional) # Your other custom settings
├── git-access-keys/
│ ├── cm-platform-gitops # Git access private key
│ └── cm-platform-gitops.pub # Git access public key
├── image-registries-auth/
│ └── cm-images.json # OCI registry credentials
└── own-cert/ # (Optional) # Custom certificates
├── fullchain.pem
└── privkey.pem
Deployment
1. Install CRDs
- Argo CD "Application"
kubectl create -f https://raw.githubusercontent.com/argoproj/argo-cd/refs/tags/v3.3.4/manifests/crds/application-crd.yaml
- Prometheus "ServiceMonitor"
This CRD might exist in your cluster, therefore,
kubectl createwill fail, which is normal behaviour.
kubectl create -f https://raw.githubusercontent.com/prometheus-operator/prometheus-operator/v0.88.0/example/prometheus-operator-crd/monitoring.coreos.com_servicemonitors.yaml
2. Install CM Platform
Standard Installation (with Let's Encrypt, self-signed, or your own TLS Secret):
helm upgrade --install cm-platform oci://confidentialmindpub.azurecr.io/cm-platform \
--namespace cm-platform --create-namespace \
--values basic-values.yaml \
--set-file gitRepositories.gitops.accessKey=git-access-keys/cm-platform-gitops \
--set-file imageRegistries.cmApps.auth=image-registries-auth/cm-images.json
With
certificateSource: customTlsSecretyou run this same command — no certificate files are passed to Helm. Create the Secret in theistio-ingressnamespace before you run it.
Installation with Your Own Certificate Files (certificateSource: own):
helm upgrade --install cm-platform oci://confidentialmindpub.azurecr.io/cm-platform \
--namespace cm-platform --create-namespace \
--values basic-values.yaml \
--set-file gitRepositories.gitops.accessKey=git-access-keys/cm-platform-gitops \
--set-file imageRegistries.cmApps.auth=image-registries-auth/cm-images.json \
--set-file tls.own.fullchainCertificate=own-cert/fullchain.pem \
--set-file tls.own.privateKey=own-cert/privkey.pem
Post-Deployment Steps
1. Verify Deployment
- Wait until all the deployed Argo CD applications become "Synced" and "Healthy":
(it normally takes around 15–25 minutes)
watch kubectl get applications -n argocd
- For more advanced monitoring or debugging use ArgoCD UI:
# Retrieve ArgoCD admin password
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
# Port-forward to ArgoCD UI
kubectl port-forward svc/argocd-server -n argocd 8080:80
# Access ArgoCD at: https://localhost:8080
# Username: admin
# Password: (the retrieved admin password)
2. Access CM Platform
Once deployment is complete:
- Open
https://portal.<base>.<domain>.<com> - Contact CM team for initial credentials
- Proceed to deploy your first endpoint through the platform interface
Troubleshooting
Common Issues
- ArgoCD Apps Out of Sync or Unhealthy: Check Argo CD UI for the actual cause
- Let's Encrypt Certificate issues: Check DNS resolution
- DNS resolution: Verify wildcard DNS is correctly configured
- A workaround for a dynamic LB IP assignment case: If the ingress IP is not known before deployment, deploy with
certificateSource: selfsignedfirst, retrieve the IP after ArgoCD syncs (kubectl get svc istio-ingressgateway -n istio-ingress), configure DNS, then runhelm upgradeswitching toletsencrypt.
- A workaround for a dynamic LB IP assignment case: If the ingress IP is not known before deployment, deploy with
- TLS / certificate issues at the Load Balancer: Depending on the infrastructure, a load balancer may be configured to terminate TLS itself (re-encrypting traffic to the backend) rather than passing it through to Istio. This can result in the LB's own certificate being presented to clients instead of the one managed by cert-manager, or in unexpected certificate validation errors. This is not necessarily wrong — LB-level TLS termination can be intentional — but it must be taken into account when planning the TLS topology and when troubleshooting certificate issues. Verify how your LB handles port 443 (passthrough vs. termination) and align it with the intended certificate management approach.
- Quick fix — TCP passthrough: Configure the LB to forward port 443 as plain TCP to Istio without terminating TLS. Simple and effective when Istio/cert-manager manages certificates end-to-end, but may not be the right choice if LB-level TLS termination is required by your infrastructure or security policy.
- Longhorn CSI issues: Ensure Longhorn prerequisites are installed
- For K3s clusters with Longhorn CSI, install prerequisites on host OS:
- Debian/Ubuntu:
apt install open-iscsi nfs-common cryptsetupsystemctl enable --now iscsid - RedHat/CentOS:
dnf install iscsi-initiator-utils nfs-utils cryptsetupsystemctl enable --now iscsid
- Debian/Ubuntu:
- For K3s clusters with Longhorn CSI, install prerequisites on host OS:
- Sync stops early after a database volume was grown: CNPG refuses to shrink a volume, so if your values file still declares the original size, the
cm-platform-basesync fails on the database and everything sequenced after it never applies. Declare the size the volume actually has — see Database storage size. - An application reports a CORS origin error: every origin in
corsCustomAllowedOriginsmust include a scheme, soportal.example.comis rejected wherehttps://portal.example.comis accepted.helm upgradestill succeeds — the affected application (for examplemanager-go) then fails to render in ArgoCD and stops applying, and the error names the service and the offending value. Add the scheme and re-run the upgrade. See Extra CORS Origins.
Optional Settings
TLS Settings
Let's Encrypt Certificate:
tls:
certificateSource: letsencrypt
letsEncrypt:
# Email for Let's Encrypt certificate generation
email: <admin>@<your-domain>.<com>
Custom Certificate:
tls:
# Source of the TLS certificate
certificateSource: own
Make sure to create
cm-platform-$clustername/own-certdirectory and place PEM files there The files should be loaded viahelm ... --set-file ...
Externally Managed TLS Secret (advanced):
tls:
certificateSource: customTlsSecret
customTlsSecret:
# Existing kubernetes.io/tls Secret in the istio-ingress namespace
name: cm-platform-tls-<my-custom>
Use this when none of the options above fit the certificate flow — a cert-manager Issuer/ClusterIssuer
with a DNS-01 or non-ACME solver, a corporate CA, external-secrets syncing from a vault, or a Secret
created by hand. You keep full control of issuance and renewal; the platform only points the Istio ingress
Gateway at the Secret you name and creates nothing itself.
cert-manager is already installed as a platform dependency, so this is also the supported way to use it
directly: create your own Issuer or ClusterIssuer with whatever solver you need, plus a Certificate
whose secretName is the Secret you name here. The platform will not touch those CRs — only the built-in
letsencrypt and selfsigned modes create cert-manager resources of their own.
own vs customTlsSecret: the two overlap — both serve a certificate you obtained yourself — but they
differ in how that certificate reaches the cluster. own takes the private key and full chain as plaintext
PEM files passed into every helm upgrade/install of cm-platform (via --set-file), which means the key
material lives in your installer invocation and values. customTlsSecret removes that requirement
entirely: the Secret is created and rotated out-of-band, and the platform only ever learns its name.
Requirements:
- The Secret must be of type
kubernetes.io/tlsand must exist in theistio-ingressnamespace — Istio resolvescredentialNamein the gateway workload's namespace. - It must cover every enabled subdomain (
api,auth,tools,portal, pluschat/portalv2/docswhen those feature flags are on), or a wildcard for the base domain. - Create the Secret before switching
certificateSource. Until it exists, Istio has no certificate to serve and every host is unreachable over HTTPS. - Name it
cm-platform-tls-<any custom name>. Sticking to that convention keeps the platform's TLS Secret easy to spot among everything else inistio-ingress, and it keeps you off the exact namecm-platform-tls— when migrating fromletsencrypt,own, orselfsigned, ArgoCD still tracks that one from the previous mode and will prune it on the next sync. - Renewal is yours to handle. Istio picks up in-place Secret updates without a Gateway change or restart.
If the certificate is signed by a private CA that browsers don't trust, note that HSTS is only stripped for
certificateSource: selfsigned— clients will need the CA distributed to their trust stores.
CNPG Backup settings
db:
cnpg:
backup:
enabled: false # Set to 'true' to enable pgBackRest backup for CNPG.
pgBackRest:
s3:
bucket: <bucket-name>
endpointURL: <hostname>
disableVerifyTLS: false # default
region: dummy # default
accessKeyId: <access-key-id>
secretAccessKey: <access-key-secret>
encryption:
key: <encryption-key>
# Backup schedule (6-digit cron format [with seconds]). Example: '0 0 */4 * * *' = every 4 hours
schedules:
# Full backup. E.g. '0 0 2 * * 0' = Weekly full backup, Sundays at 2 AM
- type: full
time: "0 0 2 * * 0" # default
# Differential backup. E.g. '0 0 2 * * 1-6' = Daily differential backup, Mon-Sat at 2 AM
- type: diff
time: "0 0 2 * * 1-6" # default
# Incremental backup. E.g. '0 0 */1 * * *' = Incremental backup every 1 hour
- type: incr
time: "0 0 */1 * * *" # default
# Retention policy pgBackRest for backups
retention:
# Number of full backups to retain
full: 2 # default
# Number of differential backups to retain. Note that full backups are included in the count of differential backups
diff: 3 # default
Database storage size
The platform databases — postgresql and keycloak-postgresql — each get a 10 GiB volume by default. Set a larger size per database here:
db:
cnpg:
clusterSizes:
# Quoted string with a unit — a plain number is rejected.
keycloak-postgresql: "20Gi"
CNPG never shrinks a volume. If one was already expanded in the cluster, declare that size here — otherwise your values file keeps asserting the smaller original and the sync fails when CNPG refuses the decrease. To read the current sizes:
kubectl -n databases get clusters.postgresql.cnpg.io \
-o custom-columns='DATABASE:.metadata.name,SIZE:.spec.storage.size'
Only the two names above are accepted. A wrong name or an unquoted size is not caught by helm upgrade, which still succeeds — the cm-platform-base application in ArgoCD then fails to render, reports the valid names, and stops applying anything sequenced after it. So after changing this setting, verify the deployment as described under Post-Deployment Steps rather than trusting the Helm exit code.
Requires installer 0.2.15 or newer — older installers ignore the setting without warning.
Secrets Provider configuration for External Secrets Operator
secrets:
# Use External Secrets Operator (ESO) ClusterSecretStore to store the secrets or push them directly from ESO Generators to Kubernetes Secrets.
# IMPORTANT: When set to `false`, the Platform falls back to using `Kubernetes` ClusterSecretStore which stores passwords as Secrets in a dedicated namespace. Using `Kubernetes` ClusterSecretStore is not recommended for production use.
# Read more about possible providers: https://external-secrets.io/latest/provider/kubernetes/
useSecretsProvider: false
# ClusterSecretStore provider configuration. This section is passed directly to the ClusterSecretStore resource. Tested with OpenBao.
provider: {}
# It's an example configuration for OpenBao or Hashicorp Vault provider
# vault:
# address: http://openbao.openbao.svc.cluster.local:8200
# token: your-openbao-token
# It's a dummy example to illustrate how to add other providers
# exampleProvider:
# ...provider specific properties...
# Extra credentials for the secrets provider, if needed. This section includes sensitive data like tokens or passwords that can't be passed directly in the 'provider' section. E.g., for OpenBao/Vault token secret, which is then referenced in 'provider.vault.auth.tokenSecretRef' later on, it will create the required secrets in all the relevant namespaces. For non-tested providers, check External Secrets Operator documentation and CM Documentation, and create the secrets manually, if required.
providerExtraCredentials:
vault:
token: ""
Extra CORS Origins
The browser sends the hostname it loaded the page from as Origin, and each service accepts only the origins it knows about — the portal subdomain, plus auth for sign-in and chat when the chat frontend is enabled. If users reach the platform through a proxy on some other hostname, add that hostname: a proxy that rewrites Host normally passes Origin through untouched, so the platform sees a name only you know about.
corsCustomAllowedOrigins:
# Full origins, including the scheme. Each service has its own route, so
# list every one the browser calls directly.
manager:
- https://portal.example.com
keycloak:
- https://portal.example.com
managerGo:
- https://portal.example.com
manager and managerGo are separate services on separate routes — managerGo serves the /cmind.*.v1 gRPC APIs, including API key management. Set whichever ones the browser actually calls.
Model Cache Path
The node-local directory where model weights are cached on the serving nodes. It defaults to /mnt/llm_cache. Override it if that path is unsuitable on your nodes (for example, to point at a larger or faster local disk):
dependencies:
ome:
# Node-local base directory for cached model weights. Must exist (or be
# creatable) and be writable on every node that runs model-serving pods —
# it is mounted as a hostPath, not a PersistentVolume.
modelCacheBasePath: /mnt/llm_cache # default
JWT Signing Key
The platform signs its internal JWTs with a key it generates on install and keeps in the secret store.
The default is ed25519. Set the algorithm before the first install if you have a policy that requires a particular one — the key is generated once, so changing these values later has no effect on an installation that already has one:
passport:
signingKey:
# ed25519 (default), ecdsa, or rsa.
type: ed25519
# Key size. rsa: 2048, 3072 or 4096. ecdsa: 256, 384 or 521.
# ed25519 has a single size and ignores this, so leave it empty.
size: ""
To use a key you manage yourself instead of a generated one, supply it as a PEM-encoded PKCS#8 private key. A key given here takes precedence and nothing is generated:
istio:
keys:
privateKeyPem: ""
Rotating the key
The platform never replaces the key on its own, and re-running the installer keeps the one you already have. Rotating it is a manual operation, and it interrupts API traffic — plan for a maintenance window.
-
Delete the stored key from your secrets provider. It is held under
cm-platform-passport. A replacement is generated on the next sync and reaches the cluster within a few minutes. -
Restart the services that signed with the old key — they read it at startup and will not pick up a new one otherwise:
kubectl -n api-services rollout restart deployment/authn deployment/manager-go -
Requests are rejected until Istio picks up the new key set, which it caches for up to 20 minutes. Restart the control plane to cut that short:
kubectl -n istio-system rollout restart deployment/istiod
In-platform documentation site
The platform serves its own documentation site, and the portal's View documentation link points at it. To publish it, give it a subdomain:
dns:
subDomains:
docs: docs # serves the docs site at https://docs.<base>.<domain>
With the standard wildcard DNS record and a selfsigned or own (wildcard) certificate, that is the only change needed — the docs host resolves through the wildcard and is already covered by the certificate. For letsencrypt, the docs host is added to the platform certificate's SAN list and validated over HTTP-01 like every other subdomain; the wildcard DNS record already makes it resolvable, so no extra record is required.
If
dns.subDomains.docsis left unset, the platform does not route the docs site and the portal's documentation link falls back to the public site athttps://docs.confidentialmind.com. Nothing else is affected — in particular the platform certificate does not gain an unresolvable SAN.