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
;
Pin the images with image.digest in production.
Choose the store (global.database.mode)
| Mode | What the chart deploys | Server replicas | Use |
|---|---|---|---|
sqlite (default) | a ReadWriteOnce PVC | exactly 1 (no HPA, no PDB) | small teams, evaluation |
external | nothing: the mongodb:// URL comes from your Secret | any, HPA | MongoDB Atlas, your replica set, a FerretDB you run |
mongodbOperator | a MongoDBCommunity (flavour: mck) or PerconaServerMongoDB (flavour: psmdb) resource for an operator you installed | any | MongoDB managed by an operator |
ferretdb | FerretDB 2 + PDB, PostgreSQL from a CloudNativePG Cluster or an external PostgreSQL | any | recommended fully open-source HA store |
statefulset | a plain MongoDB replica set with a key file and an init Job | any | development 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-urland<fullname>-runner-db-url; server pods wait inContainerCreatinguntil it has reconciled.flavour: psmdb:global.database.mongodbOperator.imageis required andcrVersionmust match your operator. Add backups throughextraSpec.
The chart never installs an operator.
Key values
| Value | Default | Meaning |
|---|---|---|
global.database.mode | sqlite | store, see above |
global.database.external.existingSecret / urlKey | – / url | Secret with the server’s URL (external) |
global.database.allowSharedCredentials | false | let the runner use the server’s credentials or share the chart’s FerretDB (a boolean; a string is refused) |
replicaCount | 1 | server replicas (1 with sqlite) |
autoscaling.enabled | false | HorizontalPodAutoscaler (2 to 6 replicas by default; not with sqlite) |
podDisruptionBudget.enabled | true | PDB (maxUnavailable: 1) |
podAntiAffinityPreset | soft | spread replicas over nodes |
server.trustedProxies | [] | CIDRs whose X-Forwarded-For names the client; required with an Ingress or HTTPRoute |
server.allowSignUp / server.trustSignUp | false | open sign-up; trust it without verification |
server.maxAccountMiB / maxFolderMiB / maxUnverifiedAccountMiB | 256 / 64 / 1 | storage quotas |
server.maxConnectionsPerIp | 64 | connections one client may hold |
server.logFormat | json | json or text |
ingress.*, httpRoute.* | off | exposure, see TLS |
tls.enabled | false | serve HTTPS from the pod |
metrics.enabled, metrics.serviceMonitor.enabled | false | Prometheus endpoint on port 9464, ServiceMonitor |
migrations.enabled | false | run oxysdb-server migrate-schema as a pre-upgrade hook |
networkPolicy.enabled | true | NetworkPolicies, see below |
persistence.size | 2Gi | SQLite volume (kept on uninstall) |
runner.enabled | false | deploy the runner subchart |
Runner subchart (runner.*)
| Value | Default | Meaning |
|---|---|---|
runner.replicaCount | 1 | API runner replicas (file mode: exactly 1) |
runner.database.mode | follows global.database | file (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.pool | default | pool the API runner claims |
runner.pullOnlyPools | [] | extra pull-only runners: {name, pool, replicas, nodeSelector, tolerations, affinity, resources, egress, …} |
runner.persistence.perReplica.enabled / size | true / 256Mi | database mode: a StatefulSet with a small volume per pod (see upgrades) |
runner.masterPassword.existingSecret | generated | master password of the runner’s encrypted key store |
runner.token.existingSecret | generated | API 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.allowAmbientCredentials | false | let tasks use the pod’s own Kerberos, AWS role or SSH agent |
runner.proxy.socks, httpsProxy, httpProxy, noProxy | – | outbound proxy |
runner.networkPolicy.egress.restrict | false | restrict 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 / controller | networkPolicy.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 controller | preset: rke2 |
F5 NGINX Ingress Controller (nginx-ingress) | preset: nginx-inc |
| OpenShift router | preset: openshift (and networkPolicy.dns.preset: openshift) |
| a controller on the host network | preset: host-network with nodeCidrs |
Gateway API (httpRoute.enabled) | gatewayNamespaceSelector / gatewayPodSelector |
| anything else | controllerNamespaceSelector / controllerPodSelector, or from |
fromAnywhere: true, or aLoadBalancer/NodePortService, opens the API port to everyone;externalSourceCidrsnarrows it.- An open API port next to
server.trustedProxiesis refused unlessacceptForgeableForwardedFor: true: any pod in the trusted range could then forge client addresses and escape the per-client sign-in throttling. - DNS:
networkPolicy.dns.presetiskube-dns(default),openshift,nodelocal(NodeLocal DNSCache; givecidrswhen the render cannot read the cluster) orcustom. - With an Ingress, the render fails if the controller’s namespace does not exist
(
checkControllerNamespace: falseskips 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 error | Fix |
|---|---|
| more than one replica refused | sqlite and runner file mode allow one replica: change the store |
| Ingress refused without TLS or trusted proxies | set ingress.tls (or ingress.allowInsecure when TLS ends before the controller) and server.trustedProxies |
ingress.className must be nginx | name your controller with a preset, the selectors or from |
| CRD not served | install the operator (CloudNativePG, MCK or Percona) before the chart |
| generated secret missing on upgrade | restore it from a backup; the chart refuses to make new credentials for an existing store |
| runner refused on the chart’s FerretDB | accept 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.