docs / Install

Install

Production installation with Helm - ingress, TLS, secrets, licence, external PostgreSQL, network policy and the SLA portal.

The platform ships as one Helm chart, itops/itops, from https://charts.mlops.hu. The public status page is a second chart, itops/sla-portal. Both are plain charts: helm pull --untar shows everything they create.

Chart Version Contains
itops/itops 2.0.0 (app 4.2.0) core, ui, bundled PostgreSQL, ingress or Istio, network policy, the GitOps config directories
itops/sla-portal 1.4.0 the public status page with its own SQLite volume

Prerequisites

  • Kubernetes 1.23 or newer. Every chart declares kubeVersion: ">=1.23.0-0".
  • An ingress controller. ingress-nginx is the tested path and the default className. Istio is supported through istio.enabled=true, with one caveat described below.
  • cert-manager, if you want the charts to request certificates. Both charts annotate their ingresses with cert-manager.io/cluster-issuer: letsencrypt-prod. If you bring your own TLS secrets, the annotation is harmless.
  • A default storage class. The bundled PostgreSQL asks for 8 GiB and the portal for 1 GiB, both ReadWriteOnce.
  • A CNI that enforces NetworkPolicy. The chart installs a default-deny egress policy on the core pod. On a CNI that ignores policies it is cosmetic.
  • Resources. The full platform requests about 150 m CPU and 160 MiB of memory plus the bundled PostgreSQL. Core is limited to 500 m / 512 MiB.

Warning If ingress-nginx runs with use-forwarded-headers: true and proxy-real-ip-cidr is not narrowed to a trusted proxy, any client can choose its own source IP with one header. That defeats per-IP rate limiting and every IP allowlist for every ingress in the cluster, not only for ITOps. Check it before you expose anything:

kubectl get cm -n ingress-nginx ingress-nginx-controller -o jsonpath='{.data.use-forwarded-headers}'

The value should be empty or false unless a trusted load balancer in front of the cluster rewrites X-Forwarded-For. The setting name and namespace differ per distribution; on MicroK8s it is nginx-load-balancer-microk8s-conf in namespace ingress.

The values that matter

helm show values itops/itops prints the whole file with comments. These are the ones a production install sets.

Hostnames and TLS

Two ingresses: ingress is the API, uiIngress is the dashboard.

ingress:
  hosts: [{ host: api.example.com, paths: [{ path: /, pathType: Prefix }] }]
  tls: [{ hosts: [api.example.com], secretName: itops-api-tls }]
uiIngress:
  hosts: [{ host: app.example.com, paths: [{ path: /, pathType: Prefix }] }]
  tls: [{ hosts: [app.example.com], secretName: itops-ui-tls }]

The API ingress carries the annotations WebSockets need (proxy-read-timeout: "300", proxy-http-version: "1.1", the Upgrade and Connection headers) and a 10 MiB body limit for ticket attachments.

The browser needs to find the API

The UI is a static bundle; at container start the entrypoint injects the API URL into it. Two values, plus the CORS origin the core must accept:

ui:
  apiUrl: https://api.example.com
  wsHost: api.example.com
env:
  ITOPS_SECURITY_CORS_ALLOWED_ORIGINS: "https://app.example.com"

Leaving ui.apiUrl empty means "the API is on the same origin as the UI", which is only true with port-forwarding. Leaving CORS at its default (localhost only) is the single most common reason a fresh install shows a login page that cannot log in.

Secrets

Three values must be set to something random, and one must equal a fourth:

secretEnv:
  ITOPS_JWT_SECRET: "<openssl rand -hex 32>"                 # signs user sessions
  ITOPS_SECURITY_OPERATOR_API_KEY: "<openssl rand -hex 32>"  # what agents and scripts send as X-API-Key
  ITOPS_DATABASE_PASSWORD: "<openssl rand -hex 24>"
  ITOPS_LICENSE_KEY: "<the JWT you were issued>"             # optional; without it there are no plugins
postgresql:
  auth:
    password: "<the same value as ITOPS_DATABASE_PASSWORD>"

An empty operator API key does not fail: it disables authentication on the agent and push endpoints and logs a warning. Set it.

secretEnv is rendered into a Secret named <release>-secrets by Helm, which is convenient and not GitOps-safe. Two alternatives keep the values out of the values file:

secretRefs:                       # wins over a same-named secretEnv entry
  ITOPS_LICENSE_KEY: { name: itops-licence, key: token }
extraEnvFrom:                     # whole Secrets or ConfigMaps as env
  - secretRef: { name: itops-runtime }

Production mode

env:
  ITOPS_SERVER_ENVIRONMENT: production
  ITOPS_SECURITY_TRUST_PROXY: "true"   # only behind an ingress that sets X-Real-IP honestly

Production mode forces the security headers and HSTS on, rate limiting on, GraphQL introspection off, and makes the licence the only way to enable a plugin. ITOPS_SECURITY_TRUST_PROXY tells the core to read the client address from X-Real-IP; that is correct behind ingress-nginx configured as described above and wrong anywhere the header can be forged.

Install

helm repo add itops https://charts.mlops.hu
helm repo update
helm install itops itops/itops --version 2.0.0 -n itops --create-namespace -f values.yaml
kubectl -n itops rollout status deploy/itops-core --timeout=600s

First login is admin / Password123!. Change it before you do anything else; the platform will not remind you again. Then set up users, groups and sign-in.

External PostgreSQL

Disable the bundled database and point the core at yours:

postgresql:
  enabled: false
env:
  ITOPS_DATABASE_HOST: postgres.infrastructure.svc
  ITOPS_DATABASE_PORT: "5432"
  ITOPS_DATABASE_NAME: itops
  ITOPS_DATABASE_USER: itops
  ITOPS_DATABASE_SSLMODE: require        # disable | require | verify-ca | verify-full
secretEnv:
  ITOPS_DATABASE_PASSWORD: "<password>"
networkPolicy:
  extraDatabaseNamespaces: [infrastructure]   # or extraDatabaseCIDRs for a host outside the cluster

The core needs a database that exists and a user that owns it. It creates and migrates every table itself at startup.

The bundled PostgreSQL is the Bitnami chart pinned to 16.4.16, pulling from bitnamilegacy/* because Bitnami moved its free images there in August 2025. It is fine for an evaluation and for small teams. For anything you would be sad to lose, run PostgreSQL the way you run the rest of your databases.

Network policy

The chart installs a default-deny egress policy on the core pod and allows: DNS, the bundled PostgreSQL, HTTP and HTTPS to public addresses, LDAP inside the namespace, and the namespaces you list. Private ranges, link-local and the cloud metadata address 169.254.169.254 are excluded from the public rule on purpose.

networkPolicy:
  enabled: true
  allowLdapEgress: true                    # same-namespace pod labelled app=ldap on 389/636
  allowedEgressNamespaces: [sla-portal]    # 80, 8080, 443
  extraDatabaseNamespaces: []              # 5432
  extraDatabaseCIDRs: []                   # 5432

An LDAP server outside the namespace, a webhook target on a private address or an external database each need a line here, or the connection is silently refused.

Istio instead of ingress-nginx

istio:
  enabled: true
  gatewaySelector: ingressgateway
  tls: { enabled: true, mode: SIMPLE, credentialName: itops-tls }   # Secret in istio-system
ingress: { enabled: false }
uiIngress: { enabled: false }

The chart creates one Gateway and two host-based VirtualServices. Because the API and the UI stay on different hosts, the browser still makes cross-origin requests and ITOPS_SECURITY_CORS_ALLOWED_ORIGINS is still required. Path-based same-origin routing is not something the chart does today.

The public status page

The portal is separate on purpose: it has no access to the platform's database and holds only what the core pushes to it, so it can sit on a public hostname.

helm install sla-portal itops/sla-portal --version 1.4.0 -n sla-portal --create-namespace \
  --set imagePullSecrets=null \
  --set ingress.host=status.example.com \
  --set apiKey="$(openssl rand -hex 24)"

imagePullSecrets=null matters: the chart's default references a Secret named ghcr-secret that does not exist in a fresh namespace. The image is public.

Then tell the core where the portal is and what to show. ITOPS_SLA_PORTAL_URL is the in-cluster address the core pushes to; ui.slaPortalUrl is the public address the UI links to.

env:
  ITOPS_SLA_PORTAL_URL: http://sla-portal.sla-portal.svc.cluster.local
  ITOPS_SLA_PORTAL_API_KEY: "<the same key>"
ui:
  slaPortalUrl: https://status.example.com
networkPolicy:
  allowedEgressNamespaces: [sla-portal]

Which groups appear on the page, with what target and label, is the portal's slaTargets value. The portal page covers it.

Upgrade

helm repo update
helm upgrade itops itops/itops --version <new> -n itops -f values.yaml

Migrations run at startup; the startup probe waits up to ten minutes for them. Read the chart's release notes before a major version: 2.0.0 moved the ticketing configuration from values into the ticketing/ directory of the chart, which is a fork-and-edit workflow described in Ticketing configuration.

Uninstall

helm uninstall itops -n itops
kubectl -n itops delete pvc -l app.kubernetes.io/name=postgresql   # only if you mean it

Helm does not delete the PostgreSQL volume. The second line does.

Pod security, for the record

The core runs as uid 1000, non-root, with a read-only root filesystem, no capabilities, seccompProfile: RuntimeDefault and automountServiceAccountToken: false. It writes to two emptyDir volumes, /var/log/itops and /tmp. Nothing in the platform needs cluster-wide RBAC; the agent's per-namespace Role is on its own page.

Documentation for ITOps 4.2 · charts itops 2.0.0, sla-portal 1.4.0 · rendered 2026-09-11