Odoo Docker Secrets in Swarm: Store and Rotate Passwords

Store Odoo docker secrets in Swarm: render admin_passwd and the database password into odoo.conf safely, then rotate them without breaking paths.

Updated . 9 min read

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

SecretsConfigs
ForPasswords, keys, certificatesNon-sensitive files such as odoo.conf templates
At restEncrypted in the managers' Raft logNot encrypted
In the containerIn-memory file, default /run/secrets/<name>File at the target path you choose (default /<config-name>)
Size limit500 KB500 KB
Can be editedNo, create a new oneNo, 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:

odoo.conf.tmpl
[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-addons

Two 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 rotate odoo_db_password_v1 to _v2 without 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

odoo-stack.yml
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_v1

How the pieces fit:

  • The keys under secrets: (odoo_db_password, odoo_admin_passwd) become the target file names in /run/secrets/. The name: line points each key at the current versioned secret.
  • The official PostgreSQL image reads POSTGRES_PASSWORD_FILE to 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 in odoo.conf, so the rendered file wins. (The image also supports a PASSWORD_FILE variable 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.tmpl and 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:

models/shipping_client.py (excerpt)
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):

  1. Generate the password once and create the new secret from it. printf is 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 -
  2. Change the role's password in PostgreSQL, sending the SQL over stdin:
    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
    Connections Odoo already has open keep working; PostgreSQL only checks the password when a connection opens.
  3. 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:

rotate-secret.sh
#!/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 ROLE step, or the secret contains a trailing newline.
  • Deploy fails after editing odoo.conf.tmpl: configs cannot change in place. Bump CONF_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.

Rather not run the server yourself?

Get managed Odoo hosting with backups, SSL, monitoring and updates handled for you. Free for 10 days.