Odoo.sh builds each branch into its own container from a Git push, with no step in between where you can define anything before that container starts. That runs into a basic operational need: getting secrets (API keys, SMTP credentials, third-party tokens) into a running instance without putting them in the codebase or hand-typing them into the database after every deploy.
This post looks at what Odoo.sh gives you natively for that, why it falls short once you have more than one environment, and the workarounds worth weighing. It does not end on a recommended solution. We have not found one that fully satisfies, and the point here is to lay the tradeoffs out plainly rather than pretend otherwise.
1. The standard way: config parameters and neutralize.sql
Two mechanisms exist in Odoo core that touch on this, and neither is really a secrets manager.
System parameters (ir.config_parameter)
The only writable, persistent key/value store Odoo.sh gives you without a config file, and it’s just a database table. Anything stored there is cleartext, readable by anyone with backend access or a database dump, and carried into every staging or dev copy Odoo.sh clones from production, unless something specifically scrubs it out.
neutralize.sql
Any installed module can ship a <module_name>/data/neutralize.sql file.
The odoo neutralize CLI command collects one such file per installed
module and runs them all in a single transaction. Two core examples:
- base: deactivates mail servers, disables non-essential crons, flags webhook actions as neutralized.
- payment_authorize: nulls
authorize_login,authorize_transaction_key,authorize_signature_key, andauthorize_client_keyon every payment provider record.
The pattern across every addon that ships one is the same: null out the
columns holding a credential. It is a scrub, not a store. It runs after a
database copy already exists, to disarm side effects (no emails from a
staging copy, no crons hitting a production API, no payment calls with a
live key), not to provision a secret in the first place. It only clears
whatever a module’s author remembered to list; a field with no
neutralize.sql line survives untouched.
2. The on-premise fix: config parameters per environment
System parameters are one flat key/value table per database, no native
concept of “staging value vs. production value.” OCA’s
server_environment_ir_config_parameter fixes exactly this
gap for on-prem deployments: it overrides get_param/create/write so a
[ir.config_parameter] section in a per-environment file wins over the
database and blocks UI edits from sticking, though the value still gets
cached into the database on first read, so it’s made non-authoritative
there rather than removed from it.
3. Constraints of Odoo.sh
The fix above depends on exactly the channel Odoo.sh doesn’t give you: a
config file to write an [ir.config_parameter] section into. Two things
are not available, worth stating precisely because Odoo.sh does support
other forms of build-time customization, just not these:
- No custom environment variables in builds. Not documented, but holds
in practice: containers only expose the variables Odoo.sh itself injects,
not ones a project defines. It injects two:
ODOO_STAGE(dev,staging, orproduction) andODOO_VERSION, both readable fromos.environ. Useful for detecting which environment code runs in, but one-way. No equivalent path to inject a value in. - No supported way to change the Odoo config file. It lives at a
dotfile path inside the build container, for instance
~/.config/odoo/odoo.confon one build we checked. The Odoo.sh Editor (the web UI) can’t reach it. A shell session can, if you’re comfortable on a terminal, but edits made that way don’t survive the next rebuild, and some options are ignored even when the file is reachable directly.
By contrast, a requirements.txt at the repository root (or in a submodule
folder containing Odoo modules) is a supported, declarative channel for
extra Python dependencies, applied automatically on build. Config values
and secrets get nothing like it.
4. Why this is not very satisfying
On Odoo.sh specifically, the per-environment fix from section 2 has
nowhere to attach: no config file to hold an [ir.config_parameter] section, no
env var to carry one instead. What’s left is committing the secret into the
repository, or storing it as a system parameter or model field, which lands back
in the database table described in section 1, copied wholesale into every
staging and dev database unless a neutralize.sql line exists for that specific
field. Neither is a secrets-management story. Both are what’s left when the
platform gives you nowhere else to put the value.
5. The workaround we currently use
On one project, the current workaround is the standard companion module
server_environment_files, as described in
server_environment’s own documentation: an addon holding
per-environment .conf files under default/, staging/, dev/, read at
startup and merged over the base config. Production was deliberately
excluded and handled separately.
- Closes the gap from section 2 without needing a config file, giving environment-scoped values without touching the database.
- Real cost: the
.conffiles live inside the Git repository Odoo.sh builds from, trading “secrets in the database” for “secrets in the codebase,” just scoped and out of the UI.
6. What we haven’t tried yet
A GitHub Actions workflow that updates config after deploy
The idea: wait for the build, connect over SSH, update the config, restart workers.
- No build-complete webhook or event, only polling.
- Each build gets its own SSH endpoint tied to that build rather than a stable project or branch address, so the workflow needs to resolve which host to connect to before it can connect.
- Even solved, the secret still has to be staged somewhere the workflow can read it from at deploy time. Same question, one level removed.
Encrypting the value at rest
OCA’s data_encryption module gives Odoo a real
encrypted-at-rest store: an encrypted.data model, keyed by name and
environment, holding a Fernet-encrypted blob per record. The decryption key
is read from the config file, one key per environment
(encryption_key_<env>), never from the database.
server_environment_data_encryption wires
this into the same server.env.mixin used by server_environment_files,
so env-managed fields get encrypted database storage instead of a plain
default field.
It’s the cleanest encrypted-at-rest story we’ve found: the ciphertext sits safely in the database, unreadable without the key. But the key itself has to come from the config file, the exact channel section 3 already ruled out.
A secrets-manager middleware
The idea: a small service hosted outside Odoo.sh, that each database calls at
runtime to fetch a secret for its stage instead of storing it locally at all.
ODOO_STAGE already tells the instance which stage it’s running as;
ir.config_parameter could be overridden (same pattern as
server_environment_ir_config_parameter) so a cache miss on a key triggers a
call to the middleware.
This does not eliminate the “where do we put it” question, it shrinks it: instead of every secret needing a channel into Odoo.sh, only one does, the credential the instance uses to authenticate to the middleware itself. And that one credential still needs a channel Odoo.sh doesn’t give it, and the middleware becomes a new single point of failure every request depends on.
Refusing to start on a copy that was never neutralized
The idea: a module loaded server-wide (--load) that checks
database.is_neutralized before the registry opens, and refuses to load the
database if the instance is not production and the flag is not set.
Making the neutralization phase configurable
The idea: declare what a non-production copy should hold as records in a table, rather than shipping a new static SQL file each time a field needs scrubbing.
One file core already reads per installed module, which does not have to be a
list of UPDATE statements: a PL/pgSQL block that reads a configuration table
and generates them when it runs. The file never changes, the rows do.
- Reachable without any platform cooperation, since it rides the file collection core already does.
- The rules travel inside the database being neutralized, which is what makes the indirection work and also what limits it: a replacement value stored there is back in the database table from section 1, copied everywhere. Useful for sandbox endpoints and obviously-fake identifiers, not for real credentials.
- Worth combining with the middleware above: the rule sets a marker, and the instance resolves the real value at runtime from the stage it is running as.
The first three options keep secrets in the repository, keep them in the database, or add a component that itself has to be trusted with their whereabouts. The last two do not provision anything; they narrow how far a production value travels once it is already in a database, which is the other half of the same problem.