A Kubernetes operator for managing Odoo instances. Declaratively deploy, initialize, upgrade, back up, and restore Odoo databases using custom resources.
Built with kube-rs in Rust. Deployed via Helm.
| Resource | Purpose |
|---|---|
OdooInstance |
Declares a running Odoo deployment: image, replicas, ingress, filestore, database |
OdooInitJob |
One-shot job to initialize a fresh database |
OdooUpgradeJob |
Runs odoo -u against an existing database, then rolls the deployment |
OdooBackupJob |
Dumps the database and filestore to object storage |
OdooRestoreJob |
Restores a backup into an OdooInstance |
- Kubernetes 1.26+
- cert-manager (for webhook TLS)
- A PostgreSQL cluster accessible from the operator namespace
- Helm 3
# clusters.yaml
main:
host: postgres.postgres.svc.cluster.local
port: 5432
adminUser: postgres
adminPassword: secret
default: truekubectl create namespace odoo-operator
kubectl create secret generic pg-clusters -n odoo-operator \
--from-file=clusters.yaml=clusters.yamlhelm upgrade --install odoo-operator oci://ghcr.io/bemade/odoo-operator/charts/odoo-operator \
--namespace odoo-operator \
--set defaults.ingressClass=nginx \
--set defaults.ingressIssuer=letsencrypt-prodInstall the chart once per cluster: the operator is a singleton with no leader election, and upgrades briefly stop it (and its admission webhook). See One operator per cluster.
NOTE: If you have a previously installed version of the chart, you may need to completely uninstall and reinstall it. This chart is still in early and active development and breaking changes are still frequent. Uninstalling the chart does not remove all your running OdooInstances. You may also need to clear the odoo-operator ServiceAccount resource along with the ClusterRole and ClusterRoleBinding of the same name. These previously had Helm hook values on them that break installation of later versions starting at v0.13.3.
apiVersion: bemade.org/v1alpha1
kind: OdooInstance
metadata:
name: myodoo
namespace: odoo
spec:
image: odoo:18.0
replicas: 1
ingress:
hosts:
- myodoo.example.comkubectl apply -f odoo.yamlBy default, the operator automatically initializes the database with the base
module. To skip auto-init (e.g. when restoring from a backup), set
spec.init.enabled: false.
| Field | Default | Description |
|---|---|---|
image |
operator default | Odoo container image |
replicas |
1 |
Number of web pods. Set to 0 to stop the instance |
adminPasswordSecretRef.name / .key |
generated | Secret (same namespace) holding the Odoo master password. If neither this nor adminPassword is set, the operator generates one into <instance>-admin-password and sets this field |
adminPassword |
— | Deprecated: the master password as plaintext, readable by anyone who can read the OdooInstance. Mutually exclusive with adminPasswordSecretRef |
imagePullSecret |
— | Name of a kubernetes.io/dockerconfigjson secret in the operator namespace (auto-copied to instance namespace) |
ingress.hosts |
— | Hostnames to expose the instance on |
ingress.issuer |
operator default | cert-manager ClusterIssuer for TLS (ignored when gatewayRef is set) |
ingress.class |
operator default | IngressClass name |
ingress.gatewayRef.name |
operator default | Gateway name for HTTPRoute (creates HTTPRoute instead of Ingress) |
ingress.gatewayRef.namespace |
operator default | Gateway namespace for HTTPRoute |
database.cluster |
secret default | Postgres cluster name from the pg-clusters secret |
database.name |
auto-generated | Database name. Defaults to odoo_<uid> if unset |
init.enabled |
true |
Automatically initialize the database when the instance is first created (skipped when productionInstanceRef is set) |
init.modules |
["base"] |
Odoo modules to install during auto-initialization |
init.webhook |
— | Webhook to notify on init job status changes |
productionInstanceRef.name |
— | Name of a source production OdooInstance to clone from on first init. When set, the operator creates an OdooStagingRefreshJob instead of an OdooInitJob. Forbidden on environment: Production. See Staging from production |
productionInstanceRef.namespace |
same as target | Reserved for a future cross-namespace phase; must equal (or omit) the target's namespace in v1 |
filestore.storageSize |
2Gi |
PVC size. Can only be increased, not decreased |
filestore.storageClass |
operator default | StorageClass for the filestore PVC. Immutable after creation. Web and cron pods mount the filestore with mountPropagation: HostToContainer on every storage type, so a FUSE driver's recovered mount reaches running pods. The node's kubelet directory must be a shared or slave mount, as it normally is |
filestore.emptyDir |
false |
Run the filestore on a per-pod emptyDir instead of a PVC, for instances whose attachments live in external object storage and whose sessions live in a non-filesystem store. No PVC is created; an existing one is retained for as long as the instance exists (a flip never deletes it; deleting the instance still does), so the flip is reversible. Backups become database-only; restore and staging-refresh skip the filestore step. Mutually exclusive with storageSize/storageClass; values left over from before a flip are kept, inert, so flipping back reuses the retained PVC |
resources |
operator default | CPU/memory requests and limits for web pods |
cron.replicas |
1 |
Number of cron pods (see Web/Cron Split below) |
cron.resources |
same as resources |
CPU/memory requests and limits for cron pods |
strategy.type |
Recreate |
Deployment strategy (Recreate or RollingUpdate) |
strategy.rollingUpdate.maxUnavailable |
25% |
Max unavailable pods during rolling update |
strategy.rollingUpdate.maxSurge |
25% |
Max extra pods during rolling update |
probes.startupPath |
/web/health |
Startup probe path |
probes.livenessPath |
/web/health |
Liveness probe path |
probes.readinessPath |
/web/health |
Readiness probe path |
configOptions |
— | Extra key-value pairs appended to odoo.conf |
webhook.url |
— | URL to receive status change callbacks |
affinity |
operator default | Pod affinity rules |
tolerations |
operator default | Pod tolerations |
Each OdooInstance creates two Deployments:
- Web (
<name>) — runs with--max-cron-threads=0, serves HTTP traffic on ports 8069 and 8072 (websocket). Scaled byspec.replicas. - Cron (
<name>-cron) — runs with--no-http, processes scheduled actions only. Scaled byspec.cron.replicas.
This separation means you can scale web workers independently of cron processing. Cron pods don't need an HTTP port, so they have no service or ingress routing. During upgrades and restores, the cron deployment is automatically scaled to zero to avoid stale connections.
You don't need to set workers or max_cron_threads in configOptions — the
operator handles this automatically via the command-line flags on each deployment.
Set spec.productionInstanceRef on a staging OdooInstance to declaratively
tie it to a source-of-truth production instance. On first reconcile, the
operator creates an OdooStagingRefreshJob (named <instance>-auto-refresh)
that clones the prod DB + filestore into the new instance and runs
odoo neutralize — in place of the normal OdooInitJob path:
apiVersion: bemade.org/v1alpha1
kind: OdooInstance
metadata:
name: client-staging
namespace: client
spec:
image: odoo:18.0
ingress:
hosts: [client-staging.example.com]
filestore:
storageSize: 50Gi
environment: Staging
productionInstanceRef:
name: client-prodRequirements and limits:
- Forbidden on
environment: Production(rejected atkubectl applytime by a CRD CEL rule). - Same-namespace only in v1.
productionInstanceRef.namespaceis reserved for a future cross-namespace phase. - Auto-refresh fires only on first initialization (when
status.dbInitializedis false). A user-createdOdooStagingRefreshJobpre-empts the auto-create — useful for tuningfilestoreMethodorskipFilestore, which the auto-create path leaves at CRD defaults (Auto/false). - A target with
filestore.emptyDir: trueis refreshed database-only: the operator clones the database but does not touch the target's external object storage. Unless staging and production share the attachment store (or it has been copied separately), the refreshed database'sir_attachmentrows point at objects the staging instance cannot read.
By default the operator creates a networking.v1/Ingress for each instance. If your
cluster uses Istio or another Gateway API implementation, set ingress.gatewayRef to
create an HTTPRoute instead:
spec:
ingress:
hosts:
- myodoo.example.com
gatewayRef:
name: my-gateway
namespace: istio-systemTLS is not managed by the operator in Gateway API mode — configure it on your Gateway
resource (wildcard cert, cert-manager Gateway integration, etc.). The issuer field is
ignored when gatewayRef is set.
To make all instances use Gateway API by default, set the operator-level defaults:
helm upgrade odoo-operator ... \
--set defaults.gatewayRefName=my-gateway \
--set defaults.gatewayRefNamespace=istio-systemThe operator sets standard Kubernetes conditions on each OdooInstance:
| Condition | Description |
|---|---|
Ready |
True when the instance is in the Running phase |
Progressing |
True during transient phases (Provisioning, Initializing, Starting, Upgrading, Restoring, BackingUp) |
The validating webhook rejects:
spec.filestore.storageSizedecreasesspec.filestore.storageClasschanges after initial setspec.database.clusterchanges after initial setspec.filestore.storageSize/storageClassset or changed together withemptyDir: true(values left over from before a flip toemptyDirare tolerated, with a warning on the flip itself, and kept in place, inert, so that flipping back reuses the retained PVC unchanged)
A transition to filestore.emptyDir: true is allowed but returns an
admission warning spelling out the contract: the existing PVC is retained
but no longer mounted, and the instance is expected to keep attachments in
external storage and sessions in a non-filesystem store.
| Flag | Default | Description |
|---|---|---|
--postgres-clusters-secret |
postgres-clusters |
Secret name in operator namespace |
--default-odoo-image |
odoo:18.0 |
Image used when spec.image is unset |
--default-storage-class |
standard |
StorageClass when spec.filestore.storageClass is unset |
--default-storage-size |
2Gi |
PVC size when spec.filestore.storageSize is unset |
--default-ingress-class |
— | IngressClass when spec.ingress.class is unset |
--default-ingress-issuer |
— | ClusterIssuer when spec.ingress.issuer is unset |
--default-gateway-ref-name |
— | Gateway name; when both name and namespace are set, creates HTTPRoute instead of Ingress |
--default-gateway-ref-namespace |
— | Gateway namespace for the default HTTPRoute parentRef |
--default-resources |
— | JSON ResourceRequirements when spec.resources is unset |
--default-affinity |
— | JSON Affinity when spec.affinity is unset |
--default-tolerations |
— | JSON []Toleration when spec.tolerations is unset |
The OdooInstance lifecycle is driven by a declarative state machine with transitions
defined in a static table in src/controller/state_machine.rs.
See STATE_MACHINE.md for the full diagram (auto-generated with make state-machine).
| Directory | Contents |
|---|---|
src/crd/ |
Custom Resource types (OdooInstance, OdooInitJob, etc.) |
src/controller/ |
Reconciler, declarative state machine, child resource management |
src/controller/states/ |
One file per OdooInstancePhase (12 states), each with an idempotent ensure() |
src/bin/ |
CLI tools — CRD YAML generator, Mermaid state diagram generator |
scripts/ |
Shell scripts embedded into backup/restore/init Jobs |
charts/odoo-operator/ |
Helm chart |
tests/integration/ |
envtest-based integration tests (17 tests, parallel via per-test namespaces) |
tests/ |
Unit tests for helpers, job builder, and admission webhook |
testing/minikube/ |
Local minikube test cluster: setup, end-to-end smoke test, release-to-local upgrade flow (README) |
.github/workflows/ |
CI (lint + test on PRs) and release (image + Helm chart on tag push) |
# Run all tests (unit + integration)
cargo test
# Run integration tests only (requires envtest binaries)
cargo test --test integration
# Lint
cargo fmt --check && cargo clippy -- -D warnings
# Generate CRDs and sync to Helm chart
make helm-crds
# Local minikube test cluster (see testing/minikube/README.md)
make minikube-setup # cluster + dependencies + latest released operator + demo
make test-minikube # end-to-end smoke test (staging refreshes, neutralize, Mailpit)
make install REV=<rev> # build a committed revision and upgrade the cluster's operator to itLGPL-3.0-or-later — Copyright 2026 Marc Durepos, Bemade Inc.