# Secrets Management on Odoo.sh

> Odoo.sh gives you no environment variables and no reachable config file, which leaves API keys either in the repository or in the database. The options, and their tradeoffs.

**Date:** 2026-08-11
**Source:** <https://trobz.com/insights/secrets-management-on-odoo-sh/>

---



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` {#s1}

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][base-neutralize]: deactivates mail servers, disables non-essential
  crons, flags webhook actions as neutralized.
- [payment_authorize][payment_authorize-neutralize]: nulls `authorize_login`,
  `authorize_transaction_key`, `authorize_signature_key`, and
  `authorize_client_key` on 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 {#s2}

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`][server-env] 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 {#s3}

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`, or `production`) and `ODOO_VERSION`, both readable from
  `os.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.conf` on 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](#s2) 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](#s1), 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`][server-env]'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](#s2) without needing a config file, giving
  environment-scoped values without touching the database.
- Real cost: the `.conf` files 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`][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`][server-env-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](#s3) 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](#s1), 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.

[server-env]: https://github.com/OCA/server-env
[base-neutralize]: https://github.com/odoo/odoo/blob/18.0/odoo/addons/base/data/neutralize.sql
[payment_authorize-neutralize]: https://github.com/odoo/odoo/blob/18.0/addons/payment_authorize/data/neutralize.sql
[data-encryption]: https://github.com/OCA/server-env/tree/18.0/data_encryption
[server-env-data-encryption]: https://github.com/OCA/server-env/tree/18.0/server_environment_data_encryption

