Odoo needs at least two secrets: the PostgreSQL password it uses to connect, and the admin_passwd master password that protects the database manager. On Docker Swarm, both belong in Docker secrets (Odoo Docker secrets in Swarm are encrypted at rest and only sent to the nodes that need them), not in environment variables or in an odoo.conf baked into an image. This guide shows how to store Odoo's secrets in Swarm, render them into odoo.conf without writing them to disk, and rotate them without breaking the file paths Odoo reads.
The examples use Odoo 19 and PostgreSQL 16 deployed with docker stack deploy. For the overall cluster layout, see the Odoo on Docker Swarm pillar guide.
Swarm secrets vs configs
| Secrets | Configs | |
|---|---|---|
| For | Passwords, keys, certificates | Non-sensitive files such as odoo.conf templates |
| At rest | Encrypted in the managers' Raft log | Not encrypted |
| In the container | In-memory file, default /run/secrets/<name> | File at the target path you choose (default /<config-name>) |
| Size limit | 500 KB | 500 KB |
| Can be edited | No, create a new one | No, create a new one |
Secrets travel from manager to node over mutual TLS and are only sent to nodes running a task that uses them. When the task stops, the secret is unmounted. docker secret inspect never shows the value; docker config inspect does show the content, which is why passwords never go into a plain config.
Create the Odoo secrets
Run these on a manager node. tr -d '\n' strips the trailing newline so it does not end up inside the password:
openssl rand -hex 24 | tr -d '\n' | docker secret create odoo_db_password_v1 -
openssl rand -hex 24 | tr -d '\n' | docker secret create odoo_admin_passwd_v1 -
docker secret ls
The _v1 suffix matters. Secrets cannot be updated, so every rotation creates a new secret with a new name. Inside the container, the file name stays the same because you set it with target, as shown below. Hex output also avoids characters like /, + and = that can cause quoting surprises in config files.
Do not type passwords on the command line with echo; they end up in your shell history.
Render odoo.conf with a templated config
Odoo reads admin_passwd only from odoo.conf (or the command line, which is visible in docker service inspect). The clean way to get a secret into that file is a Swarm config with the Go template driver. The template pulls secret values in at the moment the container is created:
[options]
db_host = db
db_port = 5432
db_user = odoo
db_password = {{ secret "odoo_db_password" }}
admin_passwd = {{ secret "odoo_admin_passwd" }}
list_db = False
proxy_mode = True
data_dir = /var/lib/odoo
addons_path = /mnt/extra-addonsTwo rules for the secret function:
- The name inside
{{ secret "..." }}is the secret's target name in the service, not the versioned secret name. That is what lets you rotateodoo_db_password_v1to_v2without touching the template. - The secret must be attached to the same service. If it is not, the task fails to start with a "secret target not found" error.
Swarm flags a config that was filled with secret data as sensitive and does not write it to disk on the node. The template file itself contains no passwords, so it is safe to keep in Git.
The stack file
services:
db:
image: postgres:16
environment:
POSTGRES_USER: odoo
POSTGRES_DB: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/odoo_db_password
secrets:
- odoo_db_password
volumes:
- pgdata:/var/lib/postgresql/data
networks:
- backend
odoo:
image: odoo:19
command: ["odoo", "--workers=2"]
secrets:
- odoo_db_password
- odoo_admin_passwd
configs:
- source: odoo_conf
target: /etc/odoo/odoo.conf
volumes:
- filestore:/var/lib/odoo
networks:
- backend
networks:
backend:
driver: overlay
volumes:
pgdata:
filestore:
configs:
odoo_conf:
name: odoo_conf_${CONF_VERSION:-1}
file: ./odoo.conf.tmpl
template_driver: golang
secrets:
odoo_db_password:
external: true
name: odoo_db_password_v1
odoo_admin_passwd:
external: true
name: odoo_admin_passwd_v1How the pieces fit:
- The keys under
secrets:(odoo_db_password,odoo_admin_passwd) become the target file names in/run/secrets/. Thename:line points each key at the current versioned secret. - The official PostgreSQL image reads
POSTGRES_PASSWORD_FILEto set the password, but only when it initializes an empty data directory. Changing the secret later does not change the database password. Rotation has to change it in PostgreSQL too (see below). - The official Odoo image's entrypoint skips its own
--db_*arguments for any setting already present inodoo.conf, so the rendered file wins. (The image also supports aPASSWORD_FILEvariable for the database password alone, if you do not need a full template.) - Configs, like secrets, cannot be changed in place. If you edit
odoo.conf.tmpland redeploy with the same name, the deploy fails. The${CONF_VERSION}variable in the name lets you bump it:CONF_VERSION=2 docker stack deploy -c odoo-stack.yml erp.
Deploy and check:
docker stack deploy -c odoo-stack.yml erp
docker service ps erp_odoo --no-trunc
Reading secrets from a custom Odoo module
If a custom addon needs an API key, for example for a shipping or payment integration, attach it as a secret and read the file instead of storing the key in ir.config_parameter, where every admin can see it:
from pathlib import Path
def _read_secret(name, default=None):
path = Path("/run/secrets") / name
try:
return path.read_text().strip()
except FileNotFoundError:
return default
api_key = _read_secret("carrier_api_key")Read the file when you need it rather than once at import time. After a rotation, the new task starts with the new file, and nothing in Python memory holds the old value across restarts.
Rotating the Odoo database password
The important rule: always keep the same target. Removing a secret and adding a new one without target= mounts it under its new name, /run/secrets/odoo_db_password_v2, and the file Odoo and the template expect disappears.
A database password lives in two places, the secret and PostgreSQL's own role, so rotation has three steps. Run them on a manager node that can also reach the PostgreSQL container (or adapt the docker exec line to the node running erp_db):
- Generate the password once and create the new secret from it.
printfis a shell builtin, so the value never shows up in the process list or your history:NEW_PW=$(openssl rand -hex 24) printf '%s' "$NEW_PW" | docker secret create odoo_db_password_v2 - - Change the role's password in PostgreSQL, sending the SQL over stdin:
Connections Odoo already has open keep working; PostgreSQL only checks the password when a connection opens.printf "ALTER ROLE odoo PASSWORD '%s';\n" "$NEW_PW" | \ docker exec -i $(docker ps -q -f name=erp_db | head -n1) psql -U odoo -d postgres unset NEW_PW - Point Odoo at the new secret, at the same target. Swarm restarts the Odoo tasks with a freshly rendered
odoo.conf:docker service update \ --secret-rm odoo_db_password_v1 \ --secret-add source=odoo_db_password_v2,target=odoo_db_password \ erp_odoo
Between steps 2 and 3, any new database connection from the old Odoo task fails, so run step 3 right away, in a quiet hour.
The erp_db service still has odoo_db_password_v1 attached. PostgreSQL ignores it after the first start, but you cannot delete a secret that a service uses. Switch it during your next maintenance window (this restarts PostgreSQL), then remove the old secret:
docker service update \
--secret-rm odoo_db_password_v1 \
--secret-add source=odoo_db_password_v2,target=odoo_db_password \
erp_db
docker secret rm odoo_db_password_v1
Finally, change the name: lines in the stack file to _v2. Otherwise the next docker stack deploy puts the old secret back.
Rotating admin_passwd
The master password lives only in the rendered odoo.conf, so one service update does it:
openssl rand -hex 24 | tr -d '\n' | docker secret create odoo_admin_passwd_v2 -
docker service update \
--secret-rm odoo_admin_passwd_v1 \
--secret-add source=odoo_admin_passwd_v2,target=odoo_admin_passwd \
erp_odoo
docker secret rm odoo_admin_passwd_v1
Because odoo.conf is mounted read-only, changing the master password from Odoo's database manager screen will fail. That is intended: the secret is the only source of truth.
A rotation script you can run more than once
A script that removes a hard-coded secret name works once: on the second run the name no longer exists. This version looks up which secret is currently mounted at the target and replaces that one:
#!/usr/bin/env bash
# Usage: rotate-secret.sh <service> <target>
# Example: rotate-secret.sh erp_odoo odoo_admin_passwd
set -euo pipefail
SERVICE="$1"
TARGET="$2"
NEW="${TARGET}_$(date +%Y%m%d%H%M%S)"
CURRENT=$(docker service inspect "$SERVICE" --format \
'{{range .Spec.TaskTemplate.ContainerSpec.Secrets}}{{if eq .File.Name "'"$TARGET"'"}}{{.SecretName}}{{end}}{{end}}')
if [ -z "$CURRENT" ]; then
echo "No secret mounted at target $TARGET on $SERVICE" >&2
exit 1
fi
openssl rand -hex 24 | tr -d '\n' | docker secret create "$NEW" -
docker service update --detach=false \
--secret-rm "$CURRENT" \
--secret-add "source=${NEW},target=${TARGET}" \
"$SERVICE"
docker secret rm "$CURRENT"
echo "Rotated $TARGET on $SERVICE: $CURRENT -> $NEW"--detach=false makes the command wait until the update converges, so the old secret is only removed once no task uses it. If the update fails and rolls back, docker secret rm fails too, because the secret is still in use, and the old value keeps working. Use it as is for admin_passwd or API keys. For the database password, use the three steps in the previous section instead, because PostgreSQL must learn the new value too. Either way, update the name: lines in your stack file afterwards.
Protect the managers with autolock
The Raft log that stores your secrets is encrypted, but by default the key to decrypt it sits on the manager's disk. Autolock takes that key off the disk:
docker swarm update --autolock=true # prints the unlock key once
docker swarm unlock # needed after every manager restart
docker swarm unlock-key --rotate # replace the unlock key
Store the unlock key in a password manager. A manager that restarts stays locked, and serves no cluster operations, until someone unlocks it. A backup of the swarm state also needs this key to be restored.
Troubleshooting
- Task rejected with "secret target ... not found": the template references a target that is not attached to the service. Compare
{{ secret "..." }}names with the service's secret keys. - Odoo logs "password authentication failed for user odoo": the secret and the PostgreSQL role are out of sync, usually because the secret was rotated without the
ALTER ROLEstep, or the secret contains a trailing newline. - Deploy fails after editing
odoo.conf.tmpl: configs cannot change in place. BumpCONF_VERSION. - Check what is mounted:
docker service inspect erp_odoo --format '{{json .Spec.TaskTemplate.ContainerSpec.Secrets}}'.
More Swarm failure patterns are covered in the Odoo on Swarm troubleshooting guide. For local development with Compose and .env files, see Odoo Docker environment variables.
On Easy Instance, every instance gets its own randomly generated master password and its own PostgreSQL role and password, and the platform handles rotating them, so there is no rotation script to maintain. See features and pricing, or try it free for 10 days with no credit card.