Deploy on Kubernetes with Helm

Install the oxysdb Helm chart: database modes, key values, high availability, NetworkPolicies and the runner subchart.

The chart deploy/helm/oxysdb/ deploys the team server, and the remote task runner as the subchart oxysdb-runner (runner.enabled, off by default). It needs Kubernetes 1.30 or later and Helm 3. Its README.md has the full values table; this page covers the decisions you have to make.

Install

helm install oxysdb deploy/helm/oxysdb -n oxysdb --create-namespace \
  --set image.registry=registry.example.com/oxys
read -rs PW   # typed without echo, kept out of the shell history
kubectl -n oxysdb exec -i deploy/oxysdb -- oxysdb-server add-account --email [email protected] <<< "$PW"; unset PW
helm test oxysdb -n oxysdb

Pin the images with image.digest in production.

Choose the store (global.database.mode)

ModeWhat the chart deploysServer replicasUse
sqlite (default)a ReadWriteOnce PVCexactly 1 (no HPA, no PDB)small teams, evaluation
externalnothing: the mongodb:// URL comes from your Secretany, HPAMongoDB Atlas, your replica set, a FerretDB you run
mongodbOperatora MongoDBCommunity (flavour: mck) or PerconaServerMongoDB (flavour: psmdb) resource for an operator you installedanyMongoDB managed by an operator
ferretdbFerretDB 2 + PDB, PostgreSQL from a CloudNativePG Cluster or an external PostgreSQLanyrecommended fully open-source HA store
statefulseta plain MongoDB replica set with a key file and an init Jobanydevelopment and tests only

Only Secret names and keys appear in values, never URLs with passwords: the pods read their URL from a mounted file, so External Secrets, Sealed Secrets or a vault injector work unchanged.

FerretDB with CloudNativePG

Install the CloudNativePG operator first. The chart then creates the PostgreSQL Cluster with the DocumentDB image (global.database.ferretdb.postgresql.cnpg.*: instances 3, storageSize 10Gi by default), FerretDB (global.database.ferretdb.replicas, 2 by default), and separate DocumentDB users for the server and the runner (never PostgreSQL’s superuser). As with compose, DocumentDB users are not confined to their database: a runner sharing this FerretDB is refused unless global.database.allowSharedCredentials=true. To keep the runner away from the team server’s data, give it a store of its own with runner.database.mode: external.

Operators

  • flavour: mck: the operator writes the connection strings into <fullname>-db-url and <fullname>-runner-db-url; server pods wait in ContainerCreating until it has reconciled.
  • flavour: psmdb: global.database.mongodbOperator.image is required and crVersion must match your operator. Add backups through extraSpec.

The chart never installs an operator.

Key values

ValueDefaultMeaning
global.database.modesqlitestore, see above
global.database.external.existingSecret / urlKey– / urlSecret with the server’s URL (external)
global.database.allowSharedCredentialsfalselet the runner use the server’s credentials or share the chart’s FerretDB (a boolean; a string is refused)
replicaCount1server replicas (1 with sqlite)
autoscaling.enabledfalseHorizontalPodAutoscaler (2 to 6 replicas by default; not with sqlite)
podDisruptionBudget.enabledtruePDB (maxUnavailable: 1)
podAntiAffinityPresetsoftspread replicas over nodes
server.trustedProxies[]CIDRs whose X-Forwarded-For names the client; required with an Ingress or HTTPRoute
server.allowSignUp / server.trustSignUpfalseopen sign-up; trust it without verification
server.maxAccountMiB / maxFolderMiB / maxUnverifiedAccountMiB256 / 64 / 1storage quotas
server.maxConnectionsPerIp64connections one client may hold
server.logFormatjsonjson or text
ingress.*, httpRoute.*offexposure, see TLS
tls.enabledfalseserve HTTPS from the pod
metrics.enabled, metrics.serviceMonitor.enabledfalsePrometheus endpoint on port 9464, ServiceMonitor
migrations.enabledfalserun oxysdb-server migrate-schema as a pre-upgrade hook
networkPolicy.enabledtrueNetworkPolicies, see below
persistence.size2GiSQLite volume (kept on uninstall)
runner.enabledfalsedeploy the runner subchart

Runner subchart (runner.*)

ValueDefaultMeaning
runner.replicaCount1API runner replicas (file mode: exactly 1)
runner.database.modefollows global.databasefile (data dir on a PVC, one replica) or external (own URL secret)
runner.database.external.existingSecret–the runner’s own URL secret; required with an external store unless shared credentials are accepted
runner.pooldefaultpool the API runner claims
runner.pullOnlyPools[]extra pull-only runners: {name, pool, replicas, nodeSelector, tolerations, affinity, resources, egress, …}
runner.persistence.perReplica.enabled / sizetrue / 256Midatabase mode: a StatefulSet with a small volume per pod (see upgrades)
runner.masterPassword.existingSecretgeneratedmaster password of the runner’s encrypted key store
runner.token.existingSecretgeneratedAPI access token (read it from <runner-fullname>-secrets, key token)
runner.connections.credentialsSecret–key, certificate, CA and known_hosts files received connections may name
runner.connections.allowAmbientCredentialsfalselet tasks use the pod’s own Kerberos, AWS role or SSH agent
runner.proxy.socks, httpsProxy, httpProxy, noProxy–outbound proxy
runner.networkPolicy.egress.restrictfalserestrict the runner’s egress (tasks reach arbitrary databases)

Losing the master password makes the runner’s stored connections unreadable. The generated master password and token are kept on uninstall.

High availability

  • At least 2 server replicas (any mode but sqlite), with the PDB and topology spread on.
  • The server keeps no state in the process: any replica answers any request, no sticky sessions.
  • On MongoDB replica sets the server uses multi-document transactions; on FerretDB it serializes writes with a lock document (a few milliseconds per request).
  • Database-mode runners elect a leader that computes schedules; each occurrence runs once even if two replicas briefly both lead. An on-demand run is claimed by exactly one runner of its pool. A runner killed during a run leaves the run interrupted; the next occurrence runs elsewhere.
  • Shutdown is graceful: the chart derives terminationGracePeriodSeconds (65 s for the server, 35 s for the runner) and refuses a smaller explicit value.

NetworkPolicies

networkPolicy.enabled is on by default and needs a CNI that enforces NetworkPolicies. It allows: API ingress from the ingress controller only, metrics from the monitoring namespace only, egress to DNS, the in-cluster store and networkPolicy.egress.allowlist ({cidr, except, ports}). The in-cluster stores get their own policies. Pull-only runner pools accept no ingress at all.

The render checks where API traffic comes from, so a default never cuts the API off silently:

Platform / controllernetworkPolicy.ingress
ingress-nginx in ingress-nginx (default)nothing; ingress.className: nginx
ingress-nginx from the AKS guide (ingress-basic)preset: aks-ingress-basic
RKE2’s bundled controllerpreset: rke2
F5 NGINX Ingress Controller (nginx-ingress)preset: nginx-inc
OpenShift routerpreset: openshift (and networkPolicy.dns.preset: openshift)
a controller on the host networkpreset: host-network with nodeCidrs
Gateway API (httpRoute.enabled)gatewayNamespaceSelector / gatewayPodSelector
anything elsecontrollerNamespaceSelector / controllerPodSelector, or from
  • fromAnywhere: true, or a LoadBalancer/NodePort Service, opens the API port to everyone; externalSourceCidrs narrows it.
  • An open API port next to server.trustedProxies is refused unless acceptForgeableForwardedFor: true: any pod in the trusted range could then forge client addresses and escape the per-client sign-in throttling.
  • DNS: networkPolicy.dns.preset is kube-dns (default), openshift, nodelocal (NodeLocal DNSCache; give cidrs when the render cannot read the cluster) or custom.
  • With an Ingress, the render fails if the controller’s namespace does not exist (checkControllerNamespace: false skips the lookup for accounts that cannot read namespaces).

GitOps (Argo CD, helm template)

Generated credentials are created once by helm install and read back with lookup. A render without cluster access fails instead of inventing new ones: give every credential as an existingSecret (the chart README lists the keys). global.oxysdb.previewSecrets=true renders throwaway values for CI previews; never apply that output. With fullnameOverride/nameOverride, set global.oxysdb.parentFullname.

Uninstall

helm uninstall removes pods and Services but keeps the data: operator resources, the CloudNativePG Cluster, generated credentials and the SQLite PVC (helm.sh/resource-policy: keep, Argo CD Prune=false,Delete=false). A later install with the same release name and namespace adopts them. To really delete the data, back it up, then delete those resources by hand.

Troubleshooting

Render or install errorFix
more than one replica refusedsqlite and runner file mode allow one replica: change the store
Ingress refused without TLS or trusted proxiesset ingress.tls (or ingress.allowInsecure when TLS ends before the controller) and server.trustedProxies
ingress.className must be nginxname your controller with a preset, the selectors or from
CRD not servedinstall the operator (CloudNativePG, MCK or Percona) before the chart
generated secret missing on upgraderestore it from a backup; the chart refuses to make new credentials for an existing store
runner refused on the chart’s FerretDBaccept global.database.allowSharedCredentials=true or give the runner its own store
server pods stuck in ContainerCreating (mck)wait for the operator to write the URL secret

deploy/test/helm-lint.sh (lint, every mode, schema validation) and deploy/test/kind-e2e.sh (a kind cluster, every mode, helm test) validate the chart.