This Helm chart installs Odoo in a Kubernetes cluster.
Important
This helm chart is designed for @IMIO specific needs and is not intended to resolve all use cases. But we are open to contributions and suggestions to improve this helm chart.
This Helm chart targets Odoo 16.0+ using the official Odoo Docker image. The default image tag is 18.0; override with image.tag as needed.
Note
For production environments, it is recommended to use CloudNativePG for PostgreSQL. The bundled chart is primarily intended for testing and development purposes, do not use it in production.
- Kubernetes cluster 1.25+
- Helm 3.8.0+
- PV provisioner support in the underlying infrastructure.
- Postgres DB (This chart can install a PostgreSQL database based on the CloudPirates
postgreschart, pinned to PostgreSQL 17). We use it for testing purposes.
- we want to use the official Odoo Docker Image or our custom Odoo Docker Image.
- we need some specific configuration for our Odoo instance.
helm repo add imio https://imio.github.io/helm-charts
helm repo updateSince release 3.0.1, packaged charts are signed. See IMIO/helm-charts — Verify chart signatures for how to verify.
The following items can be set via --set flag during installation or configured by editing the values.yaml directly (need to download the chart first).
See the values.yaml file for more information.
helm install [RELEASE_NAME] imio/odooor by cloning this repository:
git clone https://github.com/imio/helm-odoo.git
cd helm-odoo
helm dep up
helm upgrade odoo . -f values.yaml --namespace odoo --create-namespace --installThe following table lists the configurable parameters of the helm-odoo chart and the default values.
See the values.yaml file for more information.
Database configuration is split into two clearly-scoped sections:
postgresql.*— configures the bundled CloudPiratespostgressubchart (officialdocker.io/postgresimage, pinned to PostgreSQL 17.x viapostgresql.image.tag), used only whenpostgresql.enabled: true(intended for dev/test). The subchart is aliased topostgresql, so the values key and the<release-name>-postgresqlresource names are preserved. Odoo's connection is taken frompostgresql.auth.*, the host is auto-derived as<release-name>-postgresqland the port is5432.postgresql.auth.username/.password/.databaseconfigure the superuser (this chart has no separatepostgresPassword; the named user is the superuser and gets a same-named database), or supply the password throughpostgresql.auth.existingSecret(default keypostgres-password).externalDatabase.*— connection settings for an external PostgreSQL (e.g. CloudNativePG), used only whenpostgresql.enabled: false.externalDatabase.hostis required in this mode (templating fails fast if it is empty).
Only one section is ever read at a time, depending on postgresql.enabled — no key is
shared between the bundled and external scenarios.
You can use an existing secret for the Odoo configuration.
In the values.yaml file, set the existingSecret.enabled parameter to true.
Then, create a Secret in your namespace named <release-name>-odoo-conf
(or <fullnameOverride>-odoo-conf if fullnameOverride is set), containing
the odoo.conf key.
In the values.yaml file, set the externalsecrets.enabled parameter to true.
You need to have the external-secrets.io operator installed in your cluster. See the external-secrets.io documentation for more information.
Two Vault keys are used: postgresqlKey (the PostgreSQL credentials — DB user
password + superuser) and odooKey (Odoo's master admin_passwd). Each credential
written into odoo.conf chooses its own key + property under
externalsecrets.properties.odoo, so the DB password is stored in Vault once and
shared with the PostgreSQL secret instead of being duplicated:
externalsecrets:
enabled: true
secretStoreRef:
name: vault-backend
postgresqlKey: secret/data/odoo/db # DB user password + superuser
odooKey: secret/data/odoo/app # Odoo master password
properties:
postgresql:
password: postgresql-password
adminPassword: postgresql-admin-password
odoo:
postgresqlPassword: # odoo.conf db_password
key: "" # "" -> postgresqlKey (falls back to odooKey)
property: postgresql-password
adminPasswd: # odoo.conf admin_passwd
key: "" # "" -> odooKey
property: odoo-admin-passwd- Share the DB password (default): leave
postgresqlPassword.keyempty. Both the PostgreSQL secret and odoo.conf'sdb_passwordreadpostgresqlKey— one value in Vault. To make all Odoo/DB secrets live in a single key, setpostgresqlKey == odooKey. - Split keys: set
postgresqlPassword.key(oradminPasswd.key) to any other Vault key to read that credential from a different location. - External DB (
postgresql.enabled: false): the<release>-postgresql-secretis not rendered; leavepostgresqlKeyunset and set onlyodooKey—db_passwordfalls back toodooKey(the pre-2.0.0 behavior).
Warning
existingSecret.enabled and externalsecrets.enabled are mutually exclusive.
Enabling both will cause helm install/helm upgrade to fail with an explicit error.
Choose exactly one secret backend, or leave both disabled to have the chart generate secrets from values.yaml.
Database lifecycle is handled by Helm hook Jobs that run at pre-install and
pre-upgrade — before the Odoo/cron deployments are created or re-applied. Each Job
scales any running Odoo/cron to 0, waits for the database to be reachable, then runs
the migration; Helm brings the deployments back up afterwards.
odoo:
init:
enabled: true
modules: base,webRuns odoo -i <modules> -d <db> --stop-after-init. Treat enabled as a one-shot
intent: set it true to initialise on install or to re-initialise after wiping the
DB, then set it back to false — left on, every upgrade re-runs odoo -i (which
scales Odoo to 0 for the duration, even though the command itself is a no-op on an
already-installed database).
odoo:
update:
enabled: true # set true only for a version bump (causes downtime + migration)
modules: all
maintenancePage: trueRuns odoo -u <modules> -d <db> --stop-after-init (requires an already-initialised
DB). With maintenancePage: true (requires ingress.enabled), a temporary maintenance
pod is created that the <release>-nginx Service routes to while Odoo is scaled to 0,
without touching the ingress.
Important
Set odoo.update.enabled back to false after the upgrade — left on, a
scale-to-0 migration runs on every helm upgrade.
Note
Maintenance mode does not disable these hooks. maintenance.enabled: true only
scales Odoo/cron to 0 and serves the maintenance page — the init/update Jobs still
run on the next helm install/helm upgrade. This is intentional: you can migrate
behind the maintenance page (set maintenance.enabled: true, then upgrade with
odoo.update.enabled: true, then reopen). If you enter maintenance for something that
must not touch the DB (e.g. a restore), make sure odoo.init.enabled and
odoo.update.enabled are false. When both maintenance and a hook are enabled,
helm install/helm upgrade prints a warning in its notes.
Note
Bundled PostgreSQL + hooks: Helm cannot deploy a subchart before the parent's
pre-install hooks, so postgresql.enabled: true is not up when the Jobs run. For
dev/test, run PostgreSQL as a pre-install hook via postgresql.commonAnnotations
(helm.sh/hook: pre-install, weight below 0) — see test/local.yaml. Production
should use an external database (postgresql.enabled: false + externalDatabase.*).
Optionally schedule a periodic kubectl rollout restart to recycle the Odoo pods
(e.g. to clear leaked memory or pick up a freshly pulled image tag). When enabled, the
chart renders a CronJob plus a dedicated ServiceAccount/Role/RoleBinding
(<release>-rollout-restart) scoped to get/list/watch/patch on deployments in
the release namespace.
rolloutRestart:
enabled: true
schedule: "0 3 * * 0" # weekly, Sunday 03:00 (cluster timezone / UTC)
# targets: [] # default: <release> + <release>-cron (when cron.enabled)On each run it executes kubectl rollout restart followed by kubectl rollout status
for every target deployment. When targets is empty (the default) it restarts the main
Odoo deployment and, if cron.enabled, the cron deployment too; set targets to an
explicit list of deployment names to override. concurrencyPolicy: Forbid prevents
overlapping runs. The kubectl image defaults to odoo.hooks.kubectlImage and can be
overridden via rolloutRestart.image.
Optionally expose Odoo metrics to a Prometheus Operator install (e.g.
kube-prometheus-stack). When enabled, the chart renders a ServiceMonitor
(monitoring.coreos.com/v1) that selects the main Odoo Service
(<release>-odoo, app.kubernetes.io/component: server) and scrapes the named
odoo-http port — the cron Service is deliberately excluded.
serviceMonitor:
enabled: true
labels:
release: kube-prometheus-stack # match your Prometheus serviceMonitorSelector
path: /metrics
interval: 30sRequires the Prometheus Operator CRDs to be installed in the cluster, and an Odoo
endpoint that actually serves metrics at path on the scraped port (e.g. a
metrics-exporting Odoo module) — the chart only wires the scrape, it does not add
a metrics endpoint. Most operators discover ServiceMonitors by a label
(commonly release: <prometheus-release>); set it via serviceMonitor.labels.
scrapeTimeout, scheme, relabelings and metricRelabelings are also
configurable — see values.yaml.
The chart ships safe-by-default security settings (non-root containers, dropped capabilities, a dedicated ServiceAccount with the API token disabled), but a few things still need explicit configuration for production:
- Persistence — set
persistence.enabled: truewith an appropriatestorageClass/size. Without it the filestore lives on anemptyDirand Odoo attachments/sessions are lost on pod restart. For more than one replica the filestore must beReadWriteMany. - External database — set
postgresql.enabled: falseand configureexternalDatabase.*(e.g. CloudNativePG). The bundled CloudPirates PostgreSQL uses ephemeral storage and is dev/test only. - Secrets — use
existingSecret.enabled: trueorexternalsecrets.enabled: trueinstead of inlineodoo.admin_passwd/externalDatabase.passwordso credentials never live in plaintext values. - Resources — set
resources(Odoo),nginx.resourcesandcron.resources. - securityContext — defaults assume the official Odoo image (
odoouser, uid 100 / gid 101). If you use a custom image with a different user, overridesecurityContext/containerSecurityContext/nginx.containerSecurityContextaccordingly.
Create a kind cluster:
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
EOFInstall the Nginx Ingress Controller:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yamlCreate the odoo namespace:
kubectl create namespace odooGet the IP address of the kind-control-plane:
docker container inspect kind-control-plane --format '{{ .NetworkSettings.Networks.kind.IPAddress }}'Modify the /etc/hosts file with the IP address of the kind-control-plane and add the hostnames:
nano /etc/hosts
172.18.0.2 odoo.localModify the test/local.yaml file and run the helm chart:
helm dep up
helm upgrade odoo . -f test/local.yaml --namespace odoo --create-namespace --installCleanup:
helm uninstall odoo -n odoo
# Delete resources not managed by Helm (hook Jobs/RBAC left behind by uninstall)
kubectl delete all,serviceaccount,networkpolicies \
-l app.kubernetes.io/instance=odoo \
-n odoo --ignore-not-found
kind delete clusterFeel free to contribute by making a pull request.
Please read the official Helm Contribution Guide from Helm for more information on how you can contribute to this Chart.
The bundled PostgreSQL subchart was swapped from bitnami/postgresql (which moved to the
unmaintained bitnamilegacy Docker Hub namespace) to the maintained
CloudPirates postgres
chart on the official docker.io/postgres image, pinned to PostgreSQL 17. This only affects
postgresql.enabled: true (bundled DB, dev/test) — external-database users
(postgresql.enabled: false + externalDatabase.*) are unaffected.
The new chart is aliased to postgresql, so the values key stays postgresql.* and the generated
resource names are unchanged (<release>-postgresql Service/Secret, port 5432) — no connection
changes are needed. The postgresql.* schema changed, though:
- Removed
postgresql.auth.postgresPassword— this chart has a single superuser.postgresql.auth.username/.password/.databasenow configure that superuser (Odoo connects as it and gets a same-named database). - Removed
postgresql.global.security.allowInsecureImagesandpostgresql.image.repository(bitnami-legacy specifics) — no longer needed. - Added
postgresql.image.tag, pinned to a PostgreSQL 17.x digest. - Renamed
postgresql.primary.persistence→postgresql.persistence(stillenabled: false, ephemeral, for dev/test).
The bundled DB is ephemeral and dev/test only, so there is no data migration — just re-render with
the new values. helm dep up will pull the new subchart (removing the old bitnami tarball).
externalsecrets.properties.odoo.postgresqlPassword and .adminPasswd changed from a bare
string (the remoteRef property name) to a map {key, property}, so each credential can now
pick its own Vault key — this is what lets the DB password be
shared with the PostgreSQL secret instead of
duplicated. If you overrode either property name, convert it:
# before (<= 1.3.0)
externalsecrets:
properties:
odoo:
postgresqlPassword: postgresql-password
adminPasswd: odoo-admin-passwd
# after (2.0.0)
externalsecrets:
properties:
odoo:
postgresqlPassword:
property: postgresql-password
# key: "" # optional; "" -> postgresqlKey -> odooKey
adminPasswd:
property: odoo-admin-passwd
# key: "" # optional; "" -> odooKeyIf your override only repeated the defaults (postgresql-password / odoo-admin-passwd),
just remove it and let the chart defaults apply; leaving these unset is also valid. Only
externalsecrets.enabled: true users who set these are affected. Skipping the conversion
makes helm upgrade fail at render time (cannot overwrite table with non table /
interface conversion: interface {} is string, not map) — a blocking error, never a silent
wrong value.
Important
TL;DR
- Before upgrading, delete the old Deployment(s) — the selector is now immutable:
kubectl delete deployment <release>-odoo <release>-odoo-cron -n <ns> --ignore-not-found - DB connection values moved:
postgresql.host/port/auth→externalDatabase.*(external DB), whilepostgresql.*now configures only the bundled PostgreSQL subchart. odoo.update.enablednow defaults tofalse— init/update run as Helm hook Jobs, not in the main pod.- Containers are non-root by default (official Odoo image: uid 100 / gid 101) and get a dedicated
ServiceAccount with its token disabled. Custom images with a different user must override
securityContext/containerSecurityContext.
This is a breaking release. There are three independent migrations to apply to your values.
1. PostgreSQL configuration split
The single postgresql: section that previously held both the bundled-chart config and
the database connection settings has been split into two clearly-scoped sections
(postgresql.* for the bundled PostgreSQL subchart, externalDatabase.* for an external
database). Update your values as follows:
postgresql.host/postgresql.port→externalDatabase.host/externalDatabase.port(the host is now auto-derived as<release-name>-postgresqlfor the bundled chart).postgresql.auth.admin_password→postgresql.auth.postgresPassword(bitnami-native key).- External-database connection now lives under
externalDatabase.*(read only whenpostgresql.enabled: false).
2. Init and update moved to Helm hook Jobs
Database initialization and updates are no longer run inside the main deployment — the old
init-db-odoo init container and the odoo --update … command override have been removed
in favor of Helm hook Jobs. Notable changes:
odoo.update.enablednow defaults tofalse(wastrue). Previously the running Odoo container ranodoo --updateon every start; now an enabled update is apre-install,pre-upgradehook Job that scales Odoo to 0 and migrates. Enable it only to upgrade Odoo modules, then set it back tofalse.odoo.init.enablednow drives apre-install,pre-upgradehook Job (a single Job, not per-replica / per-restart) that runs before Odoo starts; it can be re-run to re-initialise a wiped database viahelm upgrade.- New value groups:
odoo.update.maintenancePageandodoo.hooks.*(backoffLimit,ttlSecondsAfterFinished,waitForDb,kubectlImage).
Note
The update hook needs permission to scale the deployments to 0. The chart creates a
namespaced ServiceAccount/Role/RoleBinding for this automatically, rendered only while
odoo.update.enabled: true.
3. Service/Deployment selector scoping + security hardening
The main Odoo Deployment and the <release>-odoo / <release>-odoo-longpolling
Services are now scoped with app.kubernetes.io/component: server so they do not
select the cron pods (which share the base selector labels
and also expose port 8069). This release also adds non-root securityContext
defaults and a dedicated ServiceAccount with the API token disabled.
Warning
A Deployment's spec.selector is immutable, so helm upgrade from an earlier
release fails with field is immutable. Delete the old Deployment(s) first:
kubectl delete deployment <release>-odoo <release>-odoo-cron \
--namespace <namespace> --ignore-not-found
helm upgrade <release> . -f <values> --namespace <namespace>If you run a custom Odoo image whose user is not uid 100 / gid 101, override
securityContext / containerSecurityContext to match.