Kubernetes deployment — Versola Docs
VersolaVersola/docs
Pre-1.00.7.1versola.kzGitHub

Kubernetes deployment

Install Versola on Kubernetes with the versola Helm chart, the two kinds of Secret, a migration Job, ingress route groups, the mTLS PKI, the console, monitoring and known issues.

The versola Helm chart deploys auth, central, edge and the admin console into a cluster you already run. It bundles no database, no secret backend and no Prometheus: you bring those and the chart consumes them. This page walks through an install and lists the chart values. For a single server without Kubernetes use VPS deployment instead; the two paths share the configuration format and nothing else (no versola-cli, no OpenBao-backed secret files).

Prerequisites

  • Kubernetes 1.28 or newer. (The versola chart itself has no such floor; the 1.28 requirement comes from the load-generator chart in the same repository.)
  • cert-manager and trust-manager installed before the chart, because pki.enabled is true by default and renders their ClusterIssuer, Certificate and Bundle resources. Set pki.enabled: false to opt out.
  • A PostgreSQL you provide, reachable from the cluster. The standard topology is a database per service: auth, central and edge each own a database and their own migration history, with a URL, user and password of their own (the chart keeps three Postgres passwords). A single database with a schema per service (?currentSchema=auth, central, edge) is the simplest topology and is enough for a small cluster. Each service validates its schema at boot and refuses to start until migrations are applied.
  • An ingress controller and the name of its IngressClass. ingress.className is required (see Ingress). Gateway API is supported instead through ingress.gatewayAPI.
  • Helm. Helm 4 applies manifests server-side, which matters for the duplicate-environment error described under migrations.
  • Optional: Stakater Reloader, to restart pods when a Secret or certificate changes.
  • scala-cli on your workstation, to generate configuration with scripts/gen-env.scala. It is not needed in the cluster.

All images are public in ghcr.io/versolauth/ (versola-auth, versola-central, versola-edge, versola-tools), so no imagePullSecrets are needed.

Get the chart

The chart is not published to a Helm repository or an OCI registry. It lives in the product repository at k8s/versola. Check out the release tag you want to run and install from that path:

git clone --branch <version> https://github.com/versolauth/versola.git
cd versola

The image tag of every component defaults to the chart’s appVersion, and CI checks that this tag exists in the registry. At the 0.7.1 tag the chart is version 0.3.0 with appVersion: "0.6.2", so installing it without overrides deploys 0.6.2 images, not the release you checked out. Set the tags yourself:

services:
  auth:    { image: { tag: "0.7.1" } }
  central: { image: { tag: "0.7.1" } }
  edge:    { image: { tag: "0.7.1" } }
console:
  image:   { tag: "0.7.1" }   # versola-tools, which carries the console

Set pki.certSync.image.tag the same way if you enable cert-sync.

Generate configuration

Each service reads one HOCON file mounted at /app/config/env.conf, in which every secret is a ${VAR} placeholder resolved from environment variables. scripts/gen-env.scala generates those files and the values behind them as one consistent set; several values must agree between services (CENTRAL_SECRET_KEY and CLIENT_SECRETS_SECRET are shared by auth and central, and central holds the public half of edge’s key).

scala-cli run scripts/gen-env.scala

Answer k8s at the first prompt. It stays interactive and asks for each value. Read the defaults before accepting them; these three are wrong for a cluster:

PromptDo not acceptAnswer
Auth internal URLthe public URLThe in-cluster address, for example http://versola-auth:8080.
Postgres URL (three, one per service)localhostThe real JDBC URL of each service’s database. TLS is sslmode=verify-full by default and prod refuses to start without it; mount the CA bundle with extraVolumes/extraVolumeMounts and set postgres.ssl-root-cert (Configuration).
Postgres password (three)the placeholder defaultThe password that database actually has.

Every prompt also accepts a --flag=value, so a run can be scripted. Output lands in .local/env/k8s/: auth.conf, central.conf, edge.conf (with placeholders) and *.generated-secrets.env (the values), plus utils.private-key.jwk, the private key of the utils client. Keep the last one safe and do not load it into any service; see Secrets.

The two kinds of Secret

The chart consumes two Secrets and they are not interchangeable:

Values keyHoldsDelivered as
services.<name>.config.existingSecretOne key, env.conf. One Secret per service.A mounted file.
secrets.existingSecretThe values the ${VAR} placeholders reference. One Secret shared by all three services.Individual secretKeyRef entries; each Deployment pulls only the keys it needs.

Key names are not the variable names. The chart converts the variable to kebab-case and prefixes it with the service name unless it already starts with it; the two values shared by auth and central stay bare. There are 29 keys:

ServiceKeys
authauth-access-tokens-secret, auth-refresh-tokens-secret, auth-codes-secret, auth-sessions-secret, auth-passwords-secret, auth-conversation-cookie-secret, auth-session-cookie-secret, auth-user-agent-cookie-secret, auth-par-requests-secret, auth-dpop-nonces-secret, auth-jwt-private-key, auth-postgres-password, auth-admin-bootstrap-password
centralcentral-account-resource-secret, central-resource-secret, central-jwks-json, central-edge-public-jwk, central-utility-client-public-jwk, central-postgres-password
edgeedge-private-key, edge-key-id, edge-token-enc-key, edge-sessions-secret, edge-internal-secret, edge-dpop-nonce-salt, edge-native-blob-key, edge-postgres-password
shared by auth and centralcentral-secret-key, client-secrets-secret

For example ACCESS_TOKENS_SECRET of auth is auth-access-tokens-secret, AUTH_CODES_SECRET is auth-codes-secret (not auth-auth-codes-secret), and EDGE_PUBLIC_JWK of central is central-edge-public-jwk. Each service has its own Postgres password. A missing secrets.existingSecret fails the template, and a missing key surfaces as an unresolved substitution at startup, so both mistakes fail loudly. What each secret protects is in Secrets.

Create the config Secrets from the generated files without the values passing through a terminal:

kubectl create secret generic auth-config -n versola \
  --from-file=env.conf=.local/env/k8s/auth.conf \
  --dry-run=client -o yaml | kubectl apply -f -

Do the same for central-config and edge-config. Build the shared Secret the same way with one --from-literal per key, mapping variable names to key names by the rule above.

secrets.restartStrategy decides how pods notice a changed shared Secret:

ValueBehaviour
none (default)Nothing automatic; run kubectl rollout restart after rotating.
checksumThe chart adds a per-service checksum annotation over only the keys that service uses. The Helm service account must be able to get Secrets at render time; it is a no-op under helm template.
reloaderAdds reloader.stakater.com/auto: "true" for clusters running Reloader.

Running migrations

The chart does not migrate. The services validate their schema at boot and refuse to start when migrations are missing, and RUN_MIGRATIONS is hardcoded to "false" in the Deployment template. Setting it through services.<name>.extraEnv renders a duplicate env entry that Helm 4’s server-side apply rejects. A migration Job in the chart is tracked in #209.

Run versola-tools migrate as a one-shot Job of your own. It reads the same auth.conf, central.conf and edge.conf the services start from. It needs two Secrets of its own, because it wants the configs under their own names and the values keyed by variable name:

  • versola-migrate-config: keys auth.conf, central.conf, edge.conf.
  • versola-migrate-env: the union of the three *.generated-secrets.env files. The tool resolves the whole document, so every placeholder in all three files must have a value, not only the Postgres block.
apiVersion: batch/v1
kind: Job
metadata:
  name: versola-migrate
  namespace: versola
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/versolauth/versola-tools:<version>
          args: ["migrate"]            # add "--dry-run" to report without applying
          envFrom:
            - secretRef:
                name: versola-migrate-env
          volumeMounts:
            - name: config
              mountPath: /opt/versola-tools/config
              readOnly: true
      volumes:
        - name: config
          secret:
            secretName: versola-migrate-config

It creates each schema if absent and migrates each service independently. --dry-run reports what would be applied, without touching anything. Back up the database first: migrations cannot be rolled back.

Install

helm upgrade --install versola k8s/versola --namespace versola --create-namespace \
  --set services.auth.config.existingSecret=auth-config \
  --set services.central.config.existingSecret=central-config \
  --set services.edge.config.existingSecret=edge-config \
  --set secrets.existingSecret=versola-secrets \
  --set ingress.enabled=true \
  --set ingress.className=nginx \
  --set 'ingress.hosts[0].host=id.example.com' \
  --set 'ingress.hosts[0].routes={oidc,login,api}'

Run the migration Job before or right after, then wait for the pods. On a first install auth and edge come up before central answers and wait for it: both sync their configuration from central while starting, and nothing orders the three. A waiting service is Running and 0/1 Ready and logs Still waiting for the source of cache .... How the wait ends depends on the outbound HTTP pool (HTTP_CLIENT_POOL_KIND, see Configuration):

  • With dynamic (and HTTP_CLIENT_POOL_MIN=0) the service becomes ready by itself once central binds its port, and restartCount stays 0.
  • With the default fixed pool it does not. The first request to central opens all the pool’s connections; while central refuses them the pool keeps a failed connection per slot (128 by default), and each retry, every 5 seconds, consumes one. The service exits with code 1 after the two minutes and restartPolicy: Always restarts it; the new process builds a fresh pool and starts normally. Expect one restart on a cold install.

The wait is two minutes and only for a central that cannot be reached; a central that answers and rejects the request (wrong URL, wrong sync key) fails startup within seconds. Set the pool variables through services.<name>.extraEnv.

Values reference

Defaults are from the chart at this release. Placement fields (nodeSelector, tolerations, affinity) exist under global and per component; a component’s own value replaces the global one rather than merging, and they are never top-level.

Global and rollout

KeyDefaultMeaning
global.imageRegistry, global.imageRepositoryghcr.io, versolauthWhere images come from.
global.imagePullPolicyIfNotPresent
global.imagePullSecrets[]
preStopSleepSeconds5Delays SIGTERM so endpoints stop routing to a terminating pod.
terminationGracePeriodSeconds30Must cover the pre-stop sleep, the app’s 15 s graceful shutdown and a margin.
diagnosticsSizeLimit1GiSize of the emptyDir at /diagnostics (heap dump, GC log). Also per service.
metrics.prometheusAnnotationstrueAdds prometheus.io/scrape, port and path annotations to the pods.
secrets.existingSecret, secrets.restartStrategy"", noneSee above.

Services

Each of services.auth, services.central, services.edge:

Keyauthcentraledge
replicaCount111
port (PORT)808080908095
diagnosticsPort (DPORT)808180918096
additionalPort (APORT)8082n/an/a
mutualTlsPort (MPORT)8083n/an/a
resourcesrequest 250m CPU and 1Gi, limit 1Gi memorysamesame

Also per service: image.repository and image.tag, config.existingSecret and config.secretKey (env.conf), extraEnv, extraEnvFrom, and extraVolumes and extraVolumeMounts (listed in the chart’s values only for auth, but the Deployment template reads them for every service). The 1Gi limit is sized from the JVM defaults: a fixed 512 MiB heap plus capped non-heap memory (see Configuration). Probes use the diagnostics port: /liveness (initial delay 10 s, period 10 s) and /readiness (5 s, 5 s).

Availability

Per service under services.<name> (topology spread also under global). Every default is a starting point, not a guarantee.

KeyDefaultMeaning
global.topologySpreadConstraintssoft spread over kubernetes.io/hostname and topology.kubernetes.io/zone (maxSkew: 1, ScheduleAnyway)Spreads each component’s replicas over nodes and zones. Soft, because a hard constraint leaves pods Pending on a cluster with fewer nodes or zones than replicas. A constraint without labelSelector gets the component’s own labels. A service’s own topologySpreadConstraints, when non-empty, replaces this list; [] at global turns spreading off.
services.<name>.podDisruptionBudgetenabled: true, maxUnavailable: 1Rendered only while the service runs more than one replica (replicaCount, or autoscaling.minReplicas). Set minAvailable instead (with maxUnavailable null), not both.
priorityClasses.createfalsetrue creates versola-customer-facing (value 1000000, used by auth and edge) and versola-control-plane (100000, used by central), so node pressure evicts the admin plane first. Off by default because a PriorityClass is cluster-scoped. services.<name>.priorityClassName names an existing class either way.
services.<name>.autoscalingenabled: false; auth 2 to 6, central 1 to 2, edge 2 to 6; targetCPUUtilizationPercentage: 70HorizontalPodAutoscaler. When enabled, the Deployment stops stating replicas. metrics replaces the default CPU metric with an autoscaling/v2 list, behavior slows scale-in.

Do not scale these services on memory: the JVM heap is fixed, so memory is flat in load. CPU is a weak signal for auth and edge, because RSA signing and Argon2id hashing run behind semaphores (argon2.max-concurrent) and CPU saturates at what they admit while requests queue. Prefer a Pods or External metric such as argon2_hashes_waiting through a metrics adapter (Observability). central is the admin plane and is better left at a fixed replica count.

Console

The admin console is a stock nginx Deployment (console.server.image, nginx:1.31-alpine by default). An init container copies the static build out of the versola-tools image (console.image) into the pod, so no console-specific web server image exists.

KeyDefaultMeaning
console.enabledtrue
console.port2821
console.basePath/Where the console answers on its host; / suits a dedicated host name.
console.modedirectHow the console addresses the API. direct calls edge’s real routes on the console’s origin and is the only mode the chart’s Ingress can serve. prefix is the VPS behaviour and needs your own proxy configuration through console.nginxConfig.
console.nginxConfig.existingConfigMap, .content""Your own nginx configuration.
console.replicaCount, console.resources1, 100m / 128Mi
console.preStopSleepSeconds, console.terminationGracePeriodSeconds5, 20

Ingress values

KeyDefaultMeaning
ingress.enabledfalse
ingress.className""Required when enabled; see below.
ingress.annotations{}
ingress.hosts[]List of { host, routes: [...] }.
ingress.tls[]Standard Ingress TLS block, passed through.
ingress.gatewayAPI.enabled, .parentRefsfalse, []Render HTTPRoutes instead of an Ingress.

PKI (mutual TLS)

KeyDefaultMeaning
pki.enabledtrueNeeds cert-manager.
pki.issuerRef{}Use an issuer you already run instead of the chart’s self-signed root.
pki.trustNamespacecert-managerWhere the CA Secret lives so trust-manager can read it.
pki.cacommonName: Versola Client CA, duration: 87600h, renewBefore: 8760hThe Versola Client CA.
pki.authServerduration: 2160h, renewBefore: 720h, ECDSA 256Leaf certificate of auth’s mTLS listener; secretName defaults to <fullname>-auth-mtls, dnsNames adds names.
pki.clients, pki.clientDefaults[]Certificates issued outside central, one cert-manager Certificate per client.
pki.trustBundle.enabled, .sourcestrue, []trust-manager Bundle that auth mounts as its trust anchors; sources adds CAs, for example the incoming one during a rotation.
pki.reloadertrueReloader annotations on auth and edge.
pki.certSyncdisabledcert-sync job that copies externally issued certificates into central (intervalSeconds 300).

Ingress

The chart exposes route groups, not individual paths. A host lists the groups it serves; nothing is exposed that is not listed, and there is no catch-all group.

GroupBackendPaths
oidcauth/authorize, /token, /par, /introspect, /revoke, /userinfo, /logout, /.well-known/, /challenge
loginedge/login/, /complete, /logout/
apiedge/resources/, /permissions/
nativeedge/native/: what a mobile app calls under FAPI 2.0. List it on any host with native clients.
consoleconsoleconsole.basePath
usersauth/users, /users/; normally reached over cluster DNS instead.
serviceauth/service/: non-prod configuration-sync tooling, 404 when env is prod.
settingsauth/settings, on auth’s additional port. Credential is a resource secret, not an end-user one.

/logout is the one overlap: auth owns it exactly, edge owns the /logout/ prefix. There is no group for central; its admin API is reached through edge’s resource proxy (api) or over cluster DNS from inside the cluster.

ingress.className is required. The template fails rather than fall back to the cluster’s default IngressClass. A stock ingress-nginx install has no default class, so an Ingress created without one is adopted by no controller: its ADDRESS stays empty and requests get a bare 404. Marking a class as default afterwards does not fix an Ingress that already exists; recreate it or set spec.ingressClassName.

A typical split puts the OIDC and login groups on the public host and the console on its own:

ingress:
  enabled: true
  className: nginx
  hosts:
    - host: id.example.com
      routes: [oidc, login]
    - host: console.example.com
      routes: [console, api, login]

Mutual TLS and PKI

With pki.enabled the chart wires up cert-manager for the certificates behind auth’s mutual-TLS listener on 8083 (container and Service port). It renders a self-signed root and the Versola Client CA ClusterIssuer (or uses pki.issuerRef), the listener’s leaf certificate, a trust-manager Bundle that auth mounts as its trust anchors, Reloader annotations, and a service account for central that may create cert-manager CertificateRequests in the release namespace and nothing else. central uses it to issue and renew certificates for edge-fronted clients: register a client with issueEdgeClientCertificate: true and an mtlsAuth, and central asks the issuer named by CLIENT_CERT_ISSUER_NAME (set by the chart) for a 14-day certificate, renewed at 4 days left.

The chart generates no env.conf; point yours at the files it mounts:

ServiceMounted atUse as
auth/app/mtls/server/tls.crt, tls.keymutual-tls.certificate, mutual-tls.private-key
auth/app/mtls/trust/ca.crtmutual-tls.trusted-certificates (the trust bundle, or the issuing CA)
edge/app/mtls/auth-pin/tls.crtnative.trusted-certificates (a leaf pin; edge gets the certificate, never the key)

gen-env writes the mutual-tls and native blocks with these paths. auth and edge read the files once at startup, so a renewal needs a restart: use pki.reloader with Reloader, or kubectl rollout restart after cert-manager renews. A renewal restarts both together, so for zero-downtime rotation of auth’s listener certificate publish the next leaf as a second pin on every edge before auth switches. For a CA rotation add the incoming CA’s Secret to pki.trustBundle.sources next to the current one, reissue the clients, then remove the outgoing source. Client certificates and the modes of obtaining them are in Edge client certificates.

Monitoring

The chart installs no Prometheus and creates no ServiceMonitor or PodMonitor. With metrics.prometheusAnnotations (default true) every pod carries prometheus.io/scrape, prometheus.io/port (the diagnostics port) and prometheus.io/path: /metrics, so an annotation-based scraper (vmagent, vanilla Prometheus) finds every replica.

Do not point a scrape target at the ClusterIP Service names. One Service fronts every replica, so the target’s instance stays fixed while the pod behind it changes per connection: rate() undercounts, and the series carries no pod label. Use pod discovery with the annotations. A working example is k8s/monitoring/vmagent-values.yaml in the product repository. What to scrape and alert on is in Observability.

Diagnostics

Each pod gets an emptyDir at /diagnostics, where the JVM writes its heap dump on an out-of-memory error and a rotating GC log (diagnosticsSizeLimit). An emptyDir survives the container restart that the OOM triggers, so kubectl cp can retrieve the dump. See Observability.

Known issues

IssueEffect
#380central’s admin API secret cannot be obtained or rotated once bootstrap has generated it. Set bootstrap.resource-secret before the first start. The same issue affects regenerating the utils key pair after central seeded it.
#209No migration Job in the chart; run your own as shown above.

Next steps

  • Secrets: what the 29 keys protect and which ones must never be regenerated.
  • Configuration: the HOCON blocks behind the config Secrets.
  • Observability: health probes, metrics and logs.