odoo-nix
On this page
Reusable Nix infrastructure for Odoo projects built on the Odoo Community Backports (OCB) distribution and OCA modules — the open alternative to Odoo Enterprise.
odoo-nix packages everything needed to develop and ship an Odoo + OCA project declaratively, so a consuming project's flake stays a thin wrapper instead of a hand-rolled monolith. From a small top-level source tree it provides:
a
gum-based scaffolder (nix run github:Avunu/odoo-nix) that optionally picks OCA bundles or modules from a bundled catalog, resolves their transitive dependency repos, and wires them in as git submodules — decline both and you get plain Odoo coreauto-synthesized
addons_path— derived from the folders actually present, so it can never drift out of sync with your submodulesa Nix-synthesized
odoo.conf(declared in your flake, symlinked into place)a devenv development shell (PostgreSQL + Odoo + Mailpit) with a reproducible Python environment via uv2nix
a
builtOdoopackage — the assembled, deployable Odoo treea single-instance NixOS module (
services.odoo-nix) with secret-safe config synthesisan OCI container image (
dockerTools)a single
odooCLI — database lifecycle (db provision/migrate/upgrade/backup/restore/…, with live progress, not just raw log output), OCA/git module management (module add,module add-bundle), workspace updates (project update), and everything else (shell,scaffold,populate, …) passed straight through to the realodoo-bin— the same binary in the dev shell,services.odoo-nix, and containers
It is consumed as a flake-parts module.
Requirements
Nix with flakes enabled, and direnv for the dev shell.
A project laid out top-level (the scaffolder writes this for you):
.
├── flake.nix # your thin wrapper (see Quick start)
├── pyproject.toml # Odoo core deps + scoped OCA deps; built by uv2nix
├── uv.lock # committed lock — drives the Nix Python env
├── modules.txt # OCA modules to install (any installable module)
├── odoo/ # OCB source (git submodule, branch = series)
├── modules/ # OCA module-repo submodules
│ ├── account-financial-reporting/
│ └── …
├── custom/ # your own Odoo modules
└── odoo.conf # symlink into the Nix store (synthesized).devenv/state/ holds the dev PostgreSQL cluster and Odoo's filestore — nothing mutable is tracked.
Create a new project
nix run github:Avunu/odoo-nix scaffolds a fresh project — the odoo-nix equivalent of a manual Odoo bootstrap. It selects an Odoo series (which fixes the Python version from a preset), optionally lets you add OCA software, resolves the dependency repos, writes the wrapper flake, adds OCB + the resolved repos as shallow git submodules, generates pyproject.toml and runs uv lock:
nix run github:Avunu/odoo-nix # interactive (gum prompts)
# or fully non-interactive:
nix run github:Avunu/odoo-nix -- \
--series 18.0 --bundles base \
--modules account_financial_report,repair_order_group \
--name acme --db acme acme
cd acme && direnv allow && devenv up # then `odoo db provision` in another shellOCA software is entirely opt-in. The scaffolder asks two independent, skippable questions — "add curated bundles?" then "add individual modules?" — each defaulting to no. Decline both (or pass neither --bundles nor --modules) and you get a plain Odoo core project; answer both and the two selections are unioned and de-duplicated. Modules can always be added later with odoo module add / odoo module add-bundle.
Series presets are curated in lib/odoo-presets.json:
| Series | Python | OCB / OCA branch |
|---|---|---|
| 18.0 | python311 | 18.0 |
| 19.0 | python312 | 19.0 |
18.0 is the default for odoo-nix.odooSeries and the series the bundled OCA catalog is richest for; 19.0 is fully supported and catalogued, but OCA's port to it is still in progress, so fewer modules exist there.
The module picker
The interactive picker lists all installable modules for the series (fuzzy search via gum filter --no-fuzzy, so typing a repo or module name does prefix matching, not loose subsequence matching). Modules flagged application in their manifest are marked with a ★ and sorted first, but every installable module is selectable — most useful OCA modules (localizations, feature modules) are not applications.
Picking a module resolves the transitive closure of OCA repos it needs (via each module's depends) and adds only the new repos as submodules. The set of modules you chose is recorded in modules.txt, which drives both installation (odoo db provision) and the scoped Python-dependency aggregation.
Quick start
A consuming flake.nix is just configuration:
{
inputs = {
self.submodules = true; # submodule contents enter the flake source tree
odoo-nix.url = "github:Avunu/odoo-nix";
nixpkgs.follows = "odoo-nix/nixpkgs";
};
outputs = { self, odoo-nix, ... }@inputs:
odoo-nix.lib.mkFlake { inherit inputs; } ({ ... }: {
imports = [ odoo-nix.flakeModules.default ];
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
perSystem = { pkgs, ... }: {
odoo-nix = {
enable = true;
name = "acme";
workspaceRoot = ./.;
odooSeries = "18.0";
python = pkgs.python311;
settings.dbName = "acme";
settings.adminPasswd = "change-me"; # dev only
};
};
});
}direnv allow # or: nix develop --no-pure-eval
devenv up # PostgreSQL + Odoo + Mailpit
odoo db provision # (another shell) create the DB + install modules.txt
# → the banner prints the server's and Mailpit's URLs: the project's own ports (see Ports)A fresh clone
The project's flake sets self.submodules = true, so Nix reads every submodule while it evaluates the flake, which is before the dev shell exists to check them out. On a fresh git clone with any change in the tree (a flake.lock being updated counts) it reads each submodule from its empty directory and fails:
fatal: '…/modules/account-analytic' does not appear to be a git repository
error: Failed to fetch git repository 'file:///…/modules/account-analytic'With a clean tree it fetches every submodule itself instead, a full clone each, past the partial clones of the paired branch the shell makes.
The scaffolded .envrc checks them out first:
if git submodule status --recursive 2> /dev/null | grep '^-' > /dev/null; then
nix run github:Avunu/odoo-nix#bootstrap -- "$PWD" || true
fi
use flake . --no-pure-evalPast the first entry that is one git submodule status and nothing else. On a fresh clone odoo-nix-bootstrap reads the odoo-nix revision flake.lock pins (from the lock alone: the flake cannot be evaluated yet) and runs that revision's odoo-nix-sources enter --treeless odoo, the shell's own checkout; a lock that pins a revision from before sources existed, or no lock, gets the bootstrap's own copy. It is a separate app, rather than all of it in the .envrc, so the .envrc stays three lines a project can paste, and the logic can be fixed in odoo-nix rather than in every project.
A project scaffolded before this has the one-line .envrc, which is the project's own file (odoo project update does not touch it). Either replace it with the one above, or, to unstick a clone once, run the checkout by hand and then direnv reload:
nix run github:Avunu/odoo-nix#sources -- enter --treeless odoo "$PWD"To see it work: clone a project, change any tracked file, direnv allow; the submodules are checked out first, then the flake evaluates (nix eval .#packages.x86_64-linux.odooConf.name).
Versioning
odoo-nix is released with release-please. Each fix: or feat: commit merged to main lands in a release pull request; merging that pull request tags vX.Y.Z and updates CHANGELOG.md and version.txt. A fix: makes a patch release (Dependabot's fix(deps) bumps of the flake inputs and the actions included) and a feat: a minor one. A major release is only ever deliberate: a Release-As: N.0.0 footer on a commit. The pr-policy check refuses ! and BREAKING CHANGE, so nothing becomes a major release by accident.
Each vN.* tag also moves a release-N branch, which only ever fast-forwards. A project that wants every 1.x release, and nothing that breaks it, follows that branch:
odoo-nix.url = "github:Avunu/odoo-nix/release-1";nix flake update odoo-nix then moves the project to the newest 1.x release. To pin one release, name its tag (github:Avunu/odoo-nix/v1.0.0); plain github:Avunu/odoo-nix follows main, unreleased changes included.
Within a major release nothing a project sets is taken away. A renamed option, command-line flag or environment variable keeps its old name as an alias, with a warning, through every release of that major, and the aliases go in the next major release: the ones in Renamed options are kept through 1.x and removed in 2.0.0. That release's changelog says what to change.
Commits and pull request titles are conventional commits, checked by committed with committed.toml (its types and odoo-nix's scopes) in the pr-policy workflow. A pull request Dependabot opened skips only the 100-character subject limit: its titles for SHA-pinned actions carry two commit SHAs.
Flake outputs
| Output | Description |
|---|---|
| flakeModules.default | the flake-parts module (devenv shell + containers + the odoo-nix option namespace) |
| nixosModules.default | the standalone services.odoo-nix NixOS module |
| lib.mkFlake | flake-parts.lib.mkFlake wrapper that merges odoo-nix's own inputs |
| lib.overrides | composable Python native-build overlays: python-devops-nix's shared fixes one by one (psycopg2, python-ldap, lxml, pycups, python-magic, pyvips, cairocffi) and libsass |
| lib.addons | the addons_path synthesis function, for testing / advanced use |
| lib.odoo-ls | the odoo-ls (Odoo language server) package builder |
| packages. | the scaffolder executable |
| packages. | odoo-ls, pinned to this repo's odoo-ls-src/odoo-ls-typeshed inputs |
| packages. | odoo-nix-sources, the dev shell's submodule checkout, outside it: nix run github:Avunu/odoo-nix#sources -- enter --treeless odoo ROOT |
| packages. | odoo-nix-bootstrap ROOT: a fresh clone's submodules, from the sources its flake.lock pins, before Nix evaluates the project (the template's .envrc; A fresh clone) |
| apps. | nix run entry point |
| apps. | nix run .#relock-odoo-ls — re-lock lib/odoo-ls-Cargo.lock |
A consuming project additionally gets packages.<sys>.{odooConf, odooPythonEnv, odooDevEnv, builtOdoo, odooCli, default, odoo-ls} from the flake-parts module. builtOdoo is the raw assembled tree; odooCli / default wrap it with the odoo CLI (bin/odoo dispatches db/module/project/shell/test, and passes anything else straight through to the real odoo-bin) — default is what services.odoo-nix.package and the container builder consume.
Options — perSystem.odoo-nix
| Option | Default | Description |
|---|---|---|
| enable | false | enable the dev shell + packages |
| name | — | the project's identity, a slug: names its environments, packages and container image, and devenv's own name (was projectName) |
| workspaceRoot | — | project root (where pyproject.toml, odoo.conf, odoo/ live) |
| coreSource | null | OCB from a non-flake input instead of the odoo/ submodule — see OCB as a flake input |
| odooSeries | "18.0" | Odoo series — the OCB/OCA branch + catalog filter (does not select a nixpkgs package) |
| python | the series' preset (lib/odoo-presets.json: python311 for 18.0, python312 for 19.0) | interpreter (match the series) |
| nodejs | pkgs.nodejs_22 | Node.js (for rtlcss / asset tooling) |
| pythonOverrides | _: _: {} | uv2nix package-set overlay for native-build overrides |
| pythonLibraries | { } | per-package native libs to expose to a Python build, e.g. { python-snappy = [ pkgs.snappy ]; } (python-devops-nix's shared fixes already cover psycopg2, python-ldap, lxml, pycups, python-magic, pyvips and cairocffi) |
| layout.coreSrc | "odoo" | path of the OCB source submodule (or of the symlink coreSource maintains) |
| layout.externalDir | "modules" | directory holding OCA module-repo submodules |
| layout.customDir | "custom" | directory holding your own modules |
| layout.extraAddons | [ ] | extra addons_path entries appended verbatim |
| devguard.enable | true | install the dev guard rails into the dev virtualenv — turning it off also makes odoo db restore refuse to run |
| devguard.egress.enable / devguard.egress.allowHosts | true / [ ] | refuse every non-loopback connection from Odoo's Python; hostnames (*.example.com), IPs or CIDRs to let through |
| devguard.mail.enable | true | redirect ALL outgoing email to the local Mailpit catcher; block IMAP/POP3 (was mailcatch.enable) |
| mailpit.enable | true | run Mailpit, before the server, whether or not the mail guard is on |
| mailpit.host / mailpit.port | "127.0.0.1" / ports.smtp | catcher SMTP endpoint (drives Mailpit and the mail guard; was devguard.mail.host/port, and mailcatch.* before that) |
| mailpit.httpPort | ports.mailUi | Mailpit web UI port (was devguard.mail.httpPort) |
| ports.offset | a hash of name | the offset every TCP port is derived from, 0 to 899 (see Ports); ODOO_NIX_PORT_OFFSET overrides it for one shell |
| ports.worktreeSalt | true | a linked worktree (git worktree add) hashes name@<path>, so it gets its own ports |
| ports.base | null | pin the HTTP port alone |
| ports.http / gevent / db / smtp / mailUi | 10000 / 11000 / 12000 / 13000 / 14000 + ports.offset | the ports the module reads (odoo.conf's http_port, gevent_port and db_port, PostgreSQL, Mailpit); pin one with lib.mkForce (was settings.httpPort/geventPort/dbPort) |
| devguard.jobs.enable / devguard.jobs.extraBlocked | true / [ ] | skip denylisted crons (auto_backup, currency rates, bank-statement pulls, EDI send, fetchmail, …); more external ids to skip. Formerly devguard.crons.* |
| devguard.objectstore.enable / backups.enable / webhooks.enable / iap.enable | true | refuse remote fs.storage; skip remote auto_backup jobs; no-op webhook actions; fail IAP calls readably |
| settings.dbHost/dbUser/dbPassword/dbName | 127.0.0.1 / odoo / False / odoo_dev | DB connection (an empty or "False" host/password is left out of odoo.conf: unix socket / no password) |
| settings.dataDir | "./.devenv/state/odoo" | Odoo filestore (gitignored under .devenv) |
| settings.adminPasswd | "admin" | DB-manager master password (dev) |
| settings.httpInterface | "127.0.0.1" | interface the dev server binds (http_interface); "0.0.0.0" to reach it from another host/container |
| settings.workers | 0 | worker processes (0 = threaded dev mode) |
| settings.devMode | "all" | --dev flag for the dev process |
| settings.logging.level | "info" | root logging verbosity (log_level) |
| settings.logging.handlers | [ ] | per-logger level overrides, e.g. [ "werkzeug |
| settings.logging.db/dbLevel | false / "warning" | mirror logs into a database (log_db/log_db_level): true = the request's own database (%d), a string = that database |
| settings.logging.file | null | write logs to this file instead of stderr (logfile); production logging is services.odoo-nix's |
| watch.python | true | restart the server on a Python change: make Odoo's --dev=reload watcher functional (see Live code reload; was dev.autoReload) |
| settings.extra | { } | arbitrary extra [options] keys merged last |
| settings.withoutDemo | false | skip demo data for every module (without_demo = True) |
| postgres.extensions | _: [ ] | extensions built into the dev PostgreSQL, e.g. ps: [ ps.postgis ] (base_geoengine) |
| postgres.noCow | true | on btrfs, keep the dev datadir off copy-on-write (chattr +C); an existing cluster is reported with the odoo-nix-db-nocow migrate "$PGDATA" that fixes it (Linux) |
| ide.enable | true | expose the env to editors: ./.venv symlink + merged odoo analysis root |
| ide.vscodeSettings | true | seed .vscode/settings.json when absent (never overwrites) |
| ide.languageServer.enable | true | run odoo-ls: odoo_ls_server on PATH + symlinked ./odools.toml — see Odoo language server |
| ide.languageServer.package | odoo-ls pinned to odoo-ls-src/odoo-ls-typeshed | the odoo_ls_server package to use |
| restore.enable | secrets.backupAccess.enable | decrypt the backup-access secret for remote restores; offer setup-backup-access |
| restore.sourceDatabase / restore.prefix | "" / "" | database folder to restore from (default: the target's name); bucket prefix when the secret has no BACKUPS_PREFIX |
| restore.files | false | also download object-store attachments (fs_attachment) on a remote restore (--files); either way the restored copy's remote fs.storage records are pointed at local disk. Was restore.attachments ("mirror" is true), read with a warning through 1.x, removed in 2.0.0 |
| restore.neutralize | true | neutralize databases restored from the remote store |
| restore.migrate | true | odoo db migrate the restored database to this checkout's modules (--no-migrate) |
| restore.requireDevguard | true | refuse to restore while devguard.enable is false (ODOO_NIX_RESTORE_ALLOW_UNGUARDED=1 overrides per command) |
| extraDevPackages / extraLibraryPaths / extraScripts / extraEnv | [] / [] / {} / {} | dev-shell extras |
| containers.enable / containers.registry | false / "" | build the OCI image; a non-empty registry prefixes its name (ghcr.io/avunu → ghcr.io/avunu/<name>-odoo) |
Renamed options
odoo-nix and frappe-nix name the jobs they share the same way (python-devops-nix CONVENTIONS.md). An old name still works through 1.x and is removed in 2.0.0 (see Versioning): it sets the new option, and evaluating the shell prints a warning naming both.
| Old | New |
|---|---|
projectName | name, which also sets devenv's own name (lib.mkDefault) |
odooConf.* | settings.*, as in services.odoo-nix; every option under it keeps its name |
mailcatch.enable | devguard.mail.enable |
mailcatch.{host,port,httpPort}, devguard.mail.{host,port,httpPort} | mailpit.{host,port,httpPort} |
dev.autoReload | watch.python; the dev tree is gone |
odooConf.{httpPort,geventPort,dbPort}, settings.{httpPort,geventPort,dbPort} | ports.{http,gevent,db}: setting one pins it, as before |
devguard.crons.* | devguard.jobs.* |
restore.attachments | restore.files ("mirror" is true) |
The environment variables odoo-nix's own tools read or export are ODOO_NIX_* (Odoo's own, such as ODOO_RC, and the guard rails' ODOO_DEVGUARD_* keep their names). An old name is still read, with a warning, through 1.x (removed in 2.0.0), and a new one that is set wins:
| Old | New |
|---|---|
ODOO_DB (the odoo CLI's default database) | ODOO_NIX_DB |
ODOO_TEST_ALLOW_SKIP | ODOO_NIX_TEST_ALLOW_SKIP |
ODOO_HTTP_PORT, ODOO_GEVENT_PORT (exported by the dev shell; the old names are still exported) | ODOO_NIX_HTTP_PORT, ODOO_NIX_GEVENT_PORT |
the container entrypoint's ODOO_<NAME> (OCI containers) | ODOO_NIX_<NAME> |
Development shell
devenv up starts:
PostgreSQL (state under
.devenv/state; Odoo connects viaodoo.conf),odoo —
odoo-bin -c odoo.conf --dev=all(threaded; serves HTTP + websockets),mailpit — SMTP sink + web UI (
mailpit.enable), started before odoo.
Ports
Every TCP port the shell binds is a base plus one offset, 0 to 899, hashed from the project's name (python-devops-nix's lib.ports, the same scheme as frappe-nix's): Odoo's HTTP port is 10000 + offset, gevent's 11000, PostgreSQL's 12000, Mailpit's SMTP and web UI 13000 and 14000. Several projects therefore run at once, every clone of one lands on the same ports, and a linked worktree (git worktree add) gets its own (ports.worktreeSalt). The banner prints the URLs. Nothing moves a port that is already taken: ports.offset or ODOO_NIX_PORT_OFFSET=<0..899> picks another offset.
The ports were fixed before (8069, 8072, 5432, 1025, 8025). To keep them:
ports = lib.mapAttrs (_: lib.mkForce) {
http = 8069;
gevent = 8072;
db = 5432;
smtp = 1025;
mailUi = 8025;
};Only the port moves: devenv rewrites postgresql.conf on every start and the data directory stays where it was, so the databases are all there on the new port.
On shell entry it checks out the git submodules a fresh clone has never had (once: one you later remove or deinitialize stays out until odoo project update) as partial clones of the branch .gitmodules pairs them with — every commit and folder but only the checkout's files (OCB: commits only), so history works offline; another branch is fetched when wanted (git remote set-branches --add origin 17.0 && git fetch origin) — and gives any shallow submodule its branch's history in place, once. On every entry it also keeps those clones from growing: it fetches a submodule's newly pinned commit the ordinary way before git submodule update can lazily re-download its whole history, merges the packs that lazy fetches leave behind, and stops fetches recursing into nested submodules; it also symlinks the synthesized odoo.conf into place, ensures the filestore + custom/ directories exist, on btrfs keeps a not-yet-created PostgreSQL datadir off copy-on-write (postgres.noCow: every page write otherwise becomes a new, compressed extent and every fsync a filesystem transaction), and refreshes the editor integration below. The submodule checkout is python-devops-nix's lib.sources (odoo-nix-sources enter; the scaffolder and odoo module add make their first clone with odoo-nix-sources clone), and its messages are odoo-nix: … lines on stderr.
CI mode. ODOO_NIX_CI set to anything but empty, 0 or false (ODOO_NIX_CI=1 nix develop --no-pure-eval -c <command>) exports PC_TUI_ENABLED=0 and DEVENV_IN_DIRENV_SHELL=true first, so devenv up runs from a script, skips the submodule checkout (the job checks out what it needs), and prints the banner as one line: odoo-nix: CI mode (<name>, Odoo <series>, http <port>). It is read when the shell starts, so one shell serves both (python-devops-nix's lib.ciMode).
Live code reload
Edit a .py file anywhere on the addons_path and the dev server restarts itself — in place, via os.execve, so the PID, the CWD and the open HTTP socket all survive. Nothing to run, no devenv up restart.
This is Odoo's own --dev=reload watcher, which devMode = "all" already requests. The catch it hides: Odoo only starts the watcher if it can import a filesystem watcher, and it declares neither inotify nor watchdog as a dependency — so --dev=all on its own logs one INFO line (Code autoreload feature is disabled) in a very noisy log and then never reloads. watch.python (on by default) closes that: the scaffolded pyproject.toml pins watchdog in [dependency-groups].dev, and for projects that predate it the dev process falls back to the nixpkgs watchdog on PYTHONPATH. Look for this line at start-up:
AutoReload watcher running with watchdogWhat it does and does not cover:
Python — every
addons_pathroot is watched recursively:odoo/odoo/addons,odoo/addons, eachmodules/<repo>, andcustom/. A change restarts the server; a syntax error is logged and the restart is skipped until you fix it.XML, QWeb, assets — no restart needed at all.
--dev=xmldrops the ORM cache onir.ui.view/ir.asset/ir.qweb, so views, templates and JS/SCSS bundles are re-read from disk. Just reload the browser.Odoo framework code (
odoo/odoo/*.py—models.py,fields.py,http.py) is not an addons root, so it is not watched. Restart the process for those.workersmust be 0 (the default). The watcher only runs on the threaded server; the dev shell warns if you set workers and leavewatch.pythonon.
Python packages are installed editable against $REPO_ROOT, so module source was always live — only the restart trigger was missing.
Editor / language-server integration
ide.enable (the default) produces two derived artifacts. Neither is read at runtime — the server and the scripts import from the store env directly — they exist so a static analyser resolves the same names the interpreter does.
| Artifact | Purpose |
|---|---|
| ./.venv → the Nix-built dev env | the venv layout every editor probes for at the workspace root |
| .devenv/state/pythonpath | a merged odoo package whose addons/ aggregates every addons_path root |
The second one is not optional dressing. Two things hide Odoo's imports from an analyser, and both need a real directory to look at:
uv2nix installs every OCA/custom module editable, through
.pthfiles whose body issys.path.append(os.path.expandvars(…)). Only a running interpreter executes those.odoo.addonsis apkgutilnamespace that Odoo extends fromaddons_pathat startup — and the bulk of the standard addons (sale,portal,mail, …) live in<coreSrc>/addons, outside theodoopackage. Nothing static can see them either.
So without it, from odoo.addons.sale.models.sale_order import SaleOrder is unresolved even for stock Odoo. The mirror links each module in first-root-wins order — the same precedence Odoo applies — and is rebuilt only when that set changes.
With ide.vscodeSettings, a .vscode/settings.json is seeded (once, never overwritten) pointing Pylance at both:
{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python",
"python.analysis.extraPaths": [".devenv/state/pythonpath"]
}For a non-VS Code editor, point your language server at the same two paths — e.g. a pyrightconfig.json with "venv": ".venv" and the same extraPaths.
Odoo language server (odoo-ls)
ide.languageServer.enable (the default, requires ide.enable) adds odoo-ls — a Rust language server that understands __manifest__.py, ORM field types and XML view/QWeb references, on top of the generic Python intelligence above. It puts odoo_ls_server on the dev shell PATH and symlinks a Nix-synthesized ./odools.toml into place (like odoo.conf and .venv — gitignored, not hand-edited), pointing it at this workspace's OCB checkout, addons_path and .venv interpreter under a single profile named "default". Every path inside it is relative to odools.toml itself (odoo-ls resolves paths against the file's own location, not the process's CWD), so its content is identical on every machine regardless of checkout path.
VS Code is a special case. The official Odoo.odoo extension bundles its own platform-specific odoo_ls_server binary inside its .vsix and has no setting to point it at another one — so the Nix-built binary above is never what runs inside VS Code. With ide.vscodeSettings, two things are seeded once (never overwritten) to help it anyway:
.vscode/extensions.jsonrecommendsOdoo.odoo, prompting VS Code to offer installing it..vscode/settings.jsongets"Odoo.serverConfigPath": "${workspaceFolder}/odools.toml", so its bundled binary picks up the same generated config.
Every other editor's LSP client can be pointed at odoo_ls_server directly (stdio transport, no initializationOptions needed) — that binary is the real payoff of ide.languageServer.enable.
Dev guard-rails
A database restored from production carries working production credentials: SMTP and IMAP logins, S3 keys, API tokens, webhook URLs, an SFTP backup target. Left alone it will mail real customers, push a dev-mutated database over the production backup rotation, delete production files and call every integration it has a key for. Odoo's neutralization covers what Odoo and a few addons remember to cover (mail servers, crons, webhook URLs, OAuth, IAP, payment providers); odoo_devguard closes the rest, and holds when neutralization was skipped or an addon ships no neutralize.sql (most don't).
It is a small Python package, grafted into the dev and test virtualenvs with a .pth file — never the production one, so services.odoo-nix and the container image cannot contain it. Every interpreter started from that environment is covered at startup, below Odoo: the server, odoo shell, odoo db ..., an editor terminal, an ad-hoc script — with no module to install into any database and no odoo.conf edit. Two layers:
| Guard | What it does | Settings |
|---|---|---|
egress | the catch-all. Refuses every connect() the process makes to a non-loopback address — requests, httpx (openai, ollama), aiohttp (s3fs), urllib3 (plaid), urllib (currency rates), paramiko (SFTP), smtplib, whatever the next addon imports. Unix sockets, loopback (Mailpit, the longpolling worker, queue_job calling back into itself; IPv4-mapped ::ffff:127.0.0.1 too), localhost and every name under it (*.localhost) and the mail catcher always pass; PostgreSQL does too, because libpq connects in C | egress.allowHosts |
mail | every SMTP connection lands on Mailpit whatever ir.mail_server record, from-filter or API transport (mail_cloudflare) a mail names; TLS/auth faked; IMAP/POP3 refused | mail.enable; the catcher is the mailpit tree |
jobs | skips denylisted scheduled actions (crons) by exact external id, even if switched back on; its log lines read odoo_devguard[crons] | jobs.extraBlocked |
objectstore | fs.storage records can only be local (file/odoofs/memory); an S3/SFTP storage cannot be opened, so attachments and backups can't reach a production bucket | |
backups | auto_backup jobs to SFTP/FS Storage are skipped, and so is their retention pass (which would delete production's backups) | |
webhooks, iap | outbound webhook actions do nothing; IAP calls fail with a readable error |
Only egress and the mail transport are transport-level (they know nothing about Odoo, so they hold across upgrades and unknown addons); the rest patch Odoo and addon APIs so failures read as "blocked by odoo-devguard" instead of a socket error, and are therefore one refactor away from being bypassed. A patch target that has moved fails the import loudly rather than leaving an inert guard (DevGuardPatchError). It is "a large reduction in blast radius plus an egress firewall for the Odoo process" — not an airgap for the machine.
Each guard says so on first use — WARNING odoo_devguard[egress] ACTIVE -- outbound connections from this process are refused (loopback only). Allow a destination with devguard.egress.allowHosts, ... — and names what it blocked. To let one thing through:
ODOO_DEVGUARD_EGRESS_ALLOW_HOSTS=api.example.com,10.0.0.0/8 odoo shell # one command
ODOO_DEVGUARD_DISABLE=jobs odoo db migrate # named guard(s) off
ODOO_DEVGUARD_ENABLED=0 odoo ... # everything off (stock behaviour)or permanently with devguard.egress.allowHosts; the environment variable replaces that list for the one command rather than adding to it. A hostname entry is matched against the name a connection was resolved from, since clients resolve a name and then connect to the address, and a refusal names that host: blocked connection to 104.16.246.239:443 (api-sandbox.gocardless.com). An address an allowlisted name shares with another host, as on a CDN, passes for both. Settings are baked into the environment from Nix and read at run time; ODOO_DEVGUARD_<GUARD>_<KEY> environment variables win, and a runtime file between the two ($ODOO_DEVGUARD_RUNTIME, else $DEVENV_RUNTIME/devguard-runtime.json, JSON in the baked shape) changes a setting for a running shell without a rebuild. The guard core is shared with frappe-nix (python-devops-nix, lib.devguard); odoo-nix supplies _product.py and the Odoo-facing guards.
Migrating from devguard.crons.*: the options are now devguard.jobs.{enable,extraBlocked} (the old names still work, with a deprecation warning, through 1.x; removed in 2.0.0). For one command, ODOO_DEVGUARD_DISABLE=jobs replaces =crons (still accepted, with a warning), and ODOO_DEVGUARD_JOBS_ENABLE / ODOO_DEVGUARD_JOBS_EXTRA_BLOCKED replace ODOO_DEVGUARD_CRONS_ENABLE / ODOO_DEVGUARD_CRONS_EXTRA_BLOCKED_CRONS; the old variables are still read as the new ones, with a warning, and a new one that is set wins.
Restore interlock. devguard.enable = false is a persistent decision about the whole project, not a per-command one, so odoo db restore and odoo project restore refuse to run while it is off (ODOO_NIX_RESTORE_ALLOW_UNGUARDED=1 overrides for one command, restore.requireDevguard = false for the project). In the dev shell a file restore is neutralized and its remote fs.storage records are pointed at local disk exactly like a remote restore (see Backups & restore).
Open the mail catcher at the URL the banner prints (mailpit.httpPort). Migrating from mailcatch.*: mailcatch.enable is devguard.mail.enable, and mailcatch.{host,port,httpPort} are mailpit.* (the old names still work with a deprecation warning); remove dev_mailcatch from any server_wide_modules you set by hand — the addon is now a no-op stub that logs a warning, kept through 1.x (removed in 2.0.0) so an old conf still starts. ODOO_MAILCATCH_* and the [dev_mailcatch] section are gone.
The odoo CLI
Everything — dev shell, services.odoo-nix, and containers — shares one odoo binary. db/module/project/shell/test are odoo-nix's own subcommands; anything else (server — the default with no subcommand —, scaffold, populate, cloc, deploy, neutralize, …) passes straight through to the real odoo-bin, so nothing from Odoo core is lost. -c/--config, -d/--database and --db-host/--db-port/--db-user/--db-password are accepted before the subcommand name, the same way odoo-bin's own flags are.
| Command | Action |
|---|---|
odoo db list | list databases, with version compatibility |
odoo db create <db> [--demo] [--lang] | create an empty database (no modules installed) |
odoo db provision [db] | create + install everything in modules.txt (new database), or migrate it (existing) — idempotent |
odoo db upgrade <m[,m2]> [db|--all] | upgrade module(s) (-u), with a live progress bar and a per-module summary table |
odoo db migrate [db|--all] [--full] [--if-needed] [--also m,…] [--snapshot zip|dump|none] [--keep N] [--rollback] [--provision-if-empty] | update the modules whose code or version changed (see Migrations) — run after pulling new code; snapshots first (zip by default), --rollback restores it on failure, --full is -u all |
odoo db duplicate <src> <dest> [--neutralize] | duplicate a database (schema + filestore) |
odoo db rename <old> <new> | rename a database (and its filestore) |
odoo db drop <db> [--yes] | drop a database and its filestore |
odoo db backup [db|--all] [--path DIR] [--format zip|dump] [--keep-days N] | dump database(s) to <data_dir>/backups/<db>/ by default; zip = schema + filestore (Odoo's own Database Manager format, the default), dump = plain pg_dump custom format, no filestore |
odoo db restore <db> [<backup-path>] [--no-force] [--neutralize] [--no-migrate] | restore a backup into <db>, with numbered progress, overwriting an existing <db> unless --no-force, then migrating it to this checkout's modules unless --no-migrate. With no path, restores the newest backup from the remote (S3) store — see Backups & restore (--from, --at, --list, --files) |
odoo module add [module …] | pick more OCA modules → resolve + add repos → record in modules.txt → re-lock |
odoo module add <git-url|owner/repo> [branch] [path] | add any third-party git repo as a submodule → record its module(s) → re-lock |
odoo module add-bundle [name …] | add a curated bundle of OCA modules (from data/oca-bundles.json) |
odoo project update [--no-migrate] | pull submodules, re-aggregate OCA Python deps, uv lock — then migrate every database, unless --no-migrate |
odoo project backup [--format zip|dump] [--path DIR] [--keep-days N] | back up every database matching dbfilter in one call — no db argument needed, unlike db backup |
odoo project restore [--restore DB PATH …] [--no-force] [--neutralize] [--no-migrate] | restore one or more explicit database=backup pairs in a single batch; with no --restore, restore the project database (settings.dbName) from the remote store — see Backups & restore |
odoo shell [db] | Odoo Python REPL |
odoo test <m[,m2]> [db] | run module tests; refuses to run if a skipped browser tour would silently read as a pass |
After odoo module add / odoo module add-bundle, run direnv reload so the Nix engine re-derives addons_path and rebuilds the Python env.
db upgrade/db migrate/db provision's progress comes from Odoo's own module-loading log records (read directly, not reimplemented) rendered as a live rich progress bar on a terminal, or narrated lines under a non-interactive stream (journald, CI) — either way ending in a summary table of what was touched, how long each module took, and which migration scripts ran. Odoo commits each module's upgrade as it completes, so a mid-migration failure is reported as "N modules already committed, module X failed" rather than implying an all-or-nothing rollback Odoo itself does not have.
Backup naming and layout match the OCA auto_backup module's own convention exactly — <data_dir>/backups/<db>/<timestamp>.dump.zip (or .dump for --format dump), timestamp YYYY_MM_DD_HH_MM_SS, no db name in the filename since the folder already carries it — so backups produced by this CLI and by an installed auto_backup module are interchangeable in the same folder. Every backup and snapshot is written owner-only (0600), and any directory the CLI creates for one is 0700; the NixOS module also keeps <data_dir>/backups itself at 0700, because nginx joins the service group under nginx.socketPath and nginx.xSendfile. Only the dump is private: what a migration writes to the filestore keeps the service's umask, so nginx can still send it. --keep-days prunes the same way auto_backup's own retention does: any backup file whose name sorts lexicographically before "now minus N days" (formatted the same way) is deleted, no stat() calls needed since the zero-padded timestamp sorts correctly as a string. project backup writes each database into its own <path>/<db>/ subfolder rather than one flat directory, since two databases backed up in the same second would otherwise collide on that db-name-free filename.
Migrations
odoo db migrate updates what changed, not everything. Two signals put a module in the update set:
its content checksum differs from the one stored the last time a migration of this database succeeded — Odoo only creates columns, reloads views and data, and runs migration scripts for modules being updated, so changed code without
-uleaves the schema silently behind it;version drift:
ir_module_module.latest_versionis behind the manifest on disk — a database restored from somewhere its modules were never updated.
The checksums use OCA module_auto_update's algorithm and storage (module_auto_update.installed_checksums, honouring module_auto_update.exclude_patterns), so it, click-odoo-update and this CLI agree on what changed and can share a database. A database with no stored checksums is updated in full once, as a baseline; db provision records the baseline for a fresh install. The plan is printed before anything runs: 3 module(s) to update: queue_job (18.0.3.4.1 → 18.0.4.0.0), sale_x (code changed), ….
Production builds also record which build migrated the database (odoo_nix.migrated_build), and --if-needed exits after one query when it matches — the no-op path every start of services.odoo-nix takes. The marker lives in the database, not on disk, so restoring a dump from elsewhere is itself what triggers the next migration.
The safety net: an advisory lock (a manual migration and a deploy cannot run at once); a snapshot verified readable before anything relies on it, in <data_dir>/backups/<db>/premigrate/ so --keep N never prunes a backup somebody took on purpose (and never prunes after a failure); and --rollback, which drops and recreates the database with the same encoding and collation and loads the snapshot. The filestore is left alone — attachments are content-addressed and only ever added — which is also why rollback does not use Odoo's own exp_drop (it deletes the filestore) or restore_db (it loads the new code against the restored old schema). An uninitialised database is skipped with exit 0, or provisioned with --provision-if-empty.
Production (services.odoo-nix) and the container image get the same binary and the same db subcommands, plus project backup/project restore (pure odoo.service.db wrappers, no workspace needed) — docker exec/ssh in and run odoo db backup mydb, odoo project backup, odoo db migrate mydb, etc. module add[-bundle]/project update are dev-shell only (no git checkout, no modules.txt, in an assembled /nix/store deployment) and fail with a clear message rather than a bare traceback if invoked there.
Backups & restore
Production pushes database backups to S3-compatible storage; any developer whose SSH key is a declared recipient restores one locally with a single command:
odoo project restore # the project database (settings.dbName), newest backup
odoo db restore acme_dev --from acme --at 2026-10-01
odoo db restore acme_dev --from acme --listThree pieces make this work, and they share one set of variables:
| Variable | Meaning |
|---|---|
BACKUPS_URL | S3 endpoint |
BACKUPS_ACCESS_KEY, BACKUPS_SECRET_KEY | credentials |
BACKUPS_BUCKET | bucket |
BACKUPS_PREFIX | optional path prefix |
Backups live at s3://$BACKUPS_BUCKET/$BACKUPS_PREFIX/<database>/<YYYY_MM_DD_HH_MM_SS>.dump[.zip] — the auto_backup naming odoo db backup also uses.
1. The credentials, in agenix. Declare who may read them in the project flake (the machinery is python-devops-nix's secrets toolkit, shared with frappe-nix):
odoo-nix.secrets = {
dir = ./secrets;
recipients.you = "ssh-ed25519 AAAA… you@host";
hostRecipients.myserver = "ssh-ed25519 AAAA… root@myserver"; # the production host
};Then, in the dev shell, setup-backup-access prompts for the five values, tests them against the bucket, and writes secrets/backup-access.age (commit it). The other commands are edit-secret <name>, rekey-secrets (after changing recipients) and check-secrets; the rules file is generated from recipients, so there is no secrets.nix to maintain, and check-secrets verifies every .age file is encrypted to exactly those keys. Secrets are decrypted per command, never at shell entry: odoo db restore re-runs itself under a wrapper that decrypts the file (one key prompt), so credentials are not in your shell's environment. Needs --no-pure-eval (use flake . --no-pure-eval in .envrc), like any agenix-shell use.
2. The server side. Add the OCA auto_backup and fs_storage modules plus auto_backup_fs_storage (a thin bridge: an FS Storage method on auto_backup) and decrypt the same ciphertext on the host:
age.secrets.odoo-backup.file = inputs.myproject.lib.secrets.files.odoo_backup_access;
services.odoo-nix.environmentFiles = [ config.age.secrets.odoo-backup.path ];(lib.odooSecrets is the old name of lib.secrets, kept through 1.x and removed in 2.0.0.) Installing the bridge module creates an odoo_backup fs.storage whose options are $BACKUPS_* references (resolved from the process environment by fs_storage's eval_options_from_env — no credentials in the database) and a daily dump-format job with 30-day retention. To give the server its own write-capable key and keep developers read-only, set odoo-nix.secrets.backupAccess.hosts = false and declare the server's file under odoo-nix.secrets.extra.
3. Restoring. odoo db restore with no file lists <prefix>/<database>/, takes the newest backup (or the newest within --at, which takes ISO 8601 — 2026-10-02, 2026-10-02T03:00 — or a backup's own name, 2026_10_02_03_00_00, or any leading part of either), downloads it to .devenv/state/odoo-nix/restore/ (size-checked; the one just fetched and one other kept), then restores it neutralized (no outgoing mail, crons off; --no-neutralize to opt out). --from names the production database folder when it differs from the local name (restore.sourceDatabase). An existing database is replaced (--no-force refuses instead; --force, the old opt-in, is accepted and does nothing through 1.x, removed in 2.0.0), and the restored database is then migrated to the checkout's modules (--no-migrate, restore.migrate).
Attachments in object storage. If production runs fs_attachment with an S3 storage, the database dump holds only <storage>://<path> pointers and a restored copy would otherwise carry production's bucket credentials and write new attachments into the production bucket. So after every remote restore, each non-local fs.storage row is rewritten to a local directory (.devenv/state/odoo-nix/attachments/<code>; protocol file, options cleared, eval_options_from_env off). With restore.files = true (or --files) the bucket contents are mirrored there first — repeat runs copy only what changed — using the backup-access credentials, which then need read access to the attachment bucket. Without it, attachments are simply missing locally. Restoring never writes to any bucket.
Format: with attachments in object storage, dump (database only) is the complete backup — the zip format's filestore would not contain them. ODOO_NIX_BACKUP_SOURCE=<dir> points the restore at a directory laid out like the bucket instead of S3 (used by the tests), and ODOO_NIX_MC names the minio-client binary. The listing, download and cache are python-devops-nix's backup fetch layer (lib.backups), which the odoo CLI imports in-process with odoo-nix's layout (lib/odoo_nix_cli/backup_layout.py); odoo-nix-backup-fetch {list,folders,fetch} is the same layer as a command, which setup-backup-access tests the credentials with.
Adding a third-party module repo
odoo module add isn't limited to the OCA catalog — give it a git URL (or owner/repo GitHub shorthand) and it adds that repo as a submodule under modules/ the same way it adds an OCA repo, then scans the clone for __manifest__.py and records whichever module(s) you pick in modules.txt. It works with any git host, not just GitHub.
odoo module add # interactive: choose "OCA catalog" or "Git URL"
odoo module add https://gitlab.com/foo/bar.git # any git host, full URL
odoo module add foo/bar # GitHub shorthand -> https://github.com/foo/bar.git
odoo module add foo/bar 17.0 my-bar # explicit branch + submodule folder nameBranch defaults to the project's odooSeries (matching OCA convention); if the repo has no such branch, its detected default branch is offered as an editable prompt (or used directly when run non-interactively). The submodule path defaults to a slug derived from the repo name, under layout.externalDir. A repo with more than one module prompts with a multi-select (nothing pre-selected) so you choose which to install; a single-module repo needs no prompt.
Bundles
odoo module add-bundle adds a named set of OCA "must-have" modules in one step — it expands the bundle to its module list and runs the same resolve → add-repos → record → re-lock flow as odoo module add. Bundles are defined in the hand-maintained data/oca-bundles.json:
{
"base": { "label": "Base — OCA must-have foundation modules", "modules": ["queue_job", "…"] },
"sales": { "label": "Sales — OCA sales workflow", "modules": ["sale_cancel_reason", "…"] }
}odoo module add-bundle # interactive picker (name — label — module count)
odoo module add-bundle base sales # by name; modules are unioned across bundlesAdd or edit a bundle by editing oca-bundles.json — no code changes needed.
The same picker and expansion are available at scaffold time (--bundles, or the first interactive prompt) — both callers share the oca_pick_bundles / oca_expand_bundles helpers in lib/oca-lib.sh. Bundles are series-blind lists, so expansion is scoped to the project's series: a member with no in-series catalog record is reported and dropped rather than recorded in modules.txt and left to fail at odoo db provision.
Python environment
The project's pyproject.toml + uv.lock are the single Python manifest, built reproducibly by uv2nix (no pip, no .venv). Dependency resolution is fully declarative — uv does all of it, with no custom aggregation:
OCA modules are modern whool packages (
odoo-addon-<module>). Each module's build metadata declares its full dependency graph from__manifest__.py:odoo-addon-<dep>for OCA depends,odoofor core depends, andexternal_dependencies.pythonas real PyPI requirements.OCB itself resolves as an
odoopath dependency — itssetup.pycarries Odoo's own requirements, so uv resolves those too (replacing anyrequirements.txttranslation).
lib/oca_sources.py generates two managed blocks (regenerated by odoo module add / odoo module add-bundle / odoo project update):
[project].dependencies—odoo+ the modules frommodules.txtasodoo-addon-<name>(the install roots);[tool.uv.sources]—odoo(the OCB submodule) + every local module as an editable path source. uv then resolves the transitive closure of the roots against these local sources and pulls only the genuine external deps from PyPI.
Modules that aren't packaged (a plain addons repo with no per-module pyproject.toml/setup.py, as Odoo itself allows) can't be uv sources. They stay on addons_path only, and are folded into the install set instead: walking from the roots, their external_dependencies.python become direct requirements and their packaged depends become odoo-addon-<dep> roots.
This is a pure enumeration of available local modules — no dependency logic. uv resolves the graph; adding a module or changing its manifest deps is picked up automatically.
The dev environment installs the modules editable (live source) and odoo as a wheel (its vendored pep517_odoo backend needs setup/ on PYTHONPATH during the build, handled in lib/python.nix); Odoo is still run from source via odoo-bin + addons_path, so module loading and OCB edits work exactly as before — the editable installs just feed uv's resolution. The package set is uv2nix's, then python-devops-nix's shared package fixes (lib.python.overrides: psycopg2, python-ldap, lxml, pycups, python-magic, pyvips, cairocffi; a build fix applies only to a source build, a runtime fix such as python-magic's libmagic path to a wheel too), then odoo-nix's own (the OCB build, lxml_html_clean), then pythonOverrides. A [tool.uv.extra-build-dependencies] block grants setuptools to sdist-only legacy deps; odoo-nix-build-deps update (python-devops-nix's lib.python.buildDeps) derives it from uv.lock after each uv lock, and odoo-nix-build-deps check fails when it is out of date. Every virtualenv goes through python-devops-nix's stale-lock audit (lib.python.lockAudit): a dependency pyproject.toml declares and uv.lock lacks (a module added to modules.txt without re-locking) fails evaluation with odoo-nix: uv.lock does not cover every declared dependency, naming it, where uv2nix alone would build the environment without it. For a genuine version conflict, use [tool.uv] override-dependencies.
When a module pulls a C-extension dep that needs system headers (e.g. pycups → cups/http.h), expose the library declaratively rather than writing an override (the shared fixes above need neither; python-magic, for one, is pointed at nixpkgs' libmagic, so it works outside the dev shell too) — the pythonLibraries option maps a Python package to nixpkgs libraries whose .dev headers + pkg-config are added to its build:
odoo-nix.pythonLibraries = {
python-snappy = [ pkgs.snappy ]; # pycups needs no entry
};OCB as a flake input
An application project keeps OCB as the odoo/ git submodule: it is the thing being deployed, and self.submodules = true puts it into the flake source. A repository that is itself consumed as an
addons path — a library of modules other odoo-nix projects mount under modules/ — should not:
every consumer with self.submodules = true would recurse into that gitlink and fetch a second copy
of OCB, and a shallow git submodule add of the library would drag it in too. Such a repository
needs OCB only for its own dev shell and test checks, so it takes it as a plain source input:
{
inputs = {
odoo-nix.url = "github:Avunu/odoo-nix";
nixpkgs.follows = "odoo-nix/nixpkgs";
ocb = {
url = "github:OCA/OCB/18.0";
flake = false;
};
};
outputs = { self, odoo-nix, ... }@inputs:
odoo-nix.lib.mkFlake { inherit inputs; } ({ inputs, ... }: {
imports = [ odoo-nix.flakeModules.default ];
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ];
perSystem = { pkgs, ... }: {
odoo-nix = {
enable = true;
name = "my-addons";
workspaceRoot = ./.;
coreSource = inputs.ocb; # no self.submodules, no odoo/ submodule
layout.customDir = "."; # the repo root is the addons directory
};
};
});
}With coreSource set:
the dev shell keeps
<coreSrc>(./odoo) as a symlink to the store path — add/odooto.gitignore— soodoo-bin, the scripts and the editor mirror all see OCB where the submodule layout has it, andpyproject.toml/uv.lockare unchanged (odoostays the path sourceodoo/).uv lock(viaodoo project update/odoo module add) is the one thing that cannot go through the link directly — setuptools writesodoo.egg-infointo the project root, and the store is read-only — so for the duration of a lock the scripts point it at a throwaway writable shadow of the tree (lib/core-shadow.sh) and restore it afterwards;the Python environment builds the
odoowheel from the store path (uv2nix would otherwise look forodoo/in the flake's copy of the workspace, where the gitignored symlink does not exist);addons_path— the devodoo.conf,lib.addons'addonsPathForused by checks, andbuiltOdoo— carries the two core roots as absolute store paths, andbuiltOdoolinks the tree instead of copying 2 GB.
Bump the pin with nix flake update ocb; odoo project update leaves it alone (it only pulls git
submodules). A repository laid out this way can be mounted by consumers straight from its default
branch.
addons_path synthesis
lib/addons.nix enumerates modules/*, keeps only directories that actually contain an Odoo module (an immediate child with __manifest__.py), prepends the two core OCB addon dirs, and appends custom/ — emitting an ordered, relative addons_path that is regenerated on every evaluation. Adding or removing a submodule changes the path automatically; there is no hand-maintained list to drift.
Entries stay workspace-relative so the file is identical across machines and containers. Absolute roots (extraAddonsAbs) are appended last, verbatim — that is how odoo-nix's own store-resident addons, such as dev_mailcatch, join the path without being vendored into the consumer's tree.
odoo.conf synthesis
lib/odoo-conf.nix renders the [options] block from your declarative settings.* options plus the derived addons_path (via pkgs.formats.ini) to a read-only /nix/store file. The dev shell symlinks it to ./odoo.conf; --dev stays a CLI flag so the same file is prod-usable.
Beside [options] it can emit arbitrary extra sections (extraSections) for modules that read their own INI section — through 18.0 out of odoo.tools.config.misc, where Odoo's parser keeps unknown sections verbatim; 19.0 dropped misc, so there a module re-reads the loaded file (config['config']) itself. OCA modules like queue_job follow the same convention.
Production — services.odoo-nix
A standalone NixOS module (imported separately from the flake-parts module). One Odoo instance per deployment; multi-tenancy via dbfilter. A base odoo.conf (no secrets) is written to the store; an odoo-init oneshot copies it to a 0600 runtime file and appends db_password / admin_passwd from secret files — so secret values never enter /nix/store.
# In a nixosConfiguration:
{
imports = [ odoo-nix.nixosModules.default ];
services.odoo-nix = {
enable = true;
package = projectFlake.packages.x86_64-linux.default; # the odoo CLI wrapper
dbName = "acme";
database.createLocally = true; # local PG, socket peer auth
adminPasswordFile = "/run/secrets/odoo-admin";
workers = 4;
nginx = { enable = true; domain = "erp.example.com"; }; # proxy_mode + /websocket → 8072
};
}odoo on a server: adminCli (default on) installs an odoo command for operators. From a root shell it runs as a transient unit with the service's user, working directory, PATH (pg_dump, psql, wkhtmltopdf…), environment and environmentFiles, so odoo db backup|restore|migrate and odoo shell behave exactly as under odoo.service and write files the service can read.
Key options: environmentFiles (systemd EnvironmentFile=s for the odoo/odoo-migrate units — agenix secrets such as the backup credentials, see Backups & restore), package (the CLI package — packages.default — which migrate requires), stateDir, http.{port,longpollingPort,interface}, workers, maxCronThreads, dbName/dbFilter/listDb/withoutDemo, database.{createLocally,host,port,user,passwordFile,extensions,ensureExtensions}, adminPasswordFile, settings (extra [options]), migrate.{enable,full,snapshot,rollbackOnFailure,snapshotRetention,timeout} (below), update (modules to update on every deploy even when unchanged), autoInit (install base + <stateDir>/modules.txt into an empty database), nginx.{enable,domain,clientMaxBodySize,socketPath}, nginx.xSendfile (Odoo's x_sendfile: Odoo still authorizes each attachment download, then nginx sends the file from the filestore through an internal location instead of a worker streaming it; off by default because anything talking to Odoo's port directly then gets empty bodies), logging.{level,handlers,db,dbLevel,journald,accessLog,slowQueryMs} (Logging, below), logging.{file,rotate} (a log file instead of the journal; rotate wires up services.logrotate against it, and Odoo's own WatchedFileHandler picks up the rotated file automatically, no reload needed).
Migrations on deploy
The schema follows the code by itself. odoo-migrate.service runs odoo db migrate <dbName> --if-needed --snapshot dump --keep <snapshotRetention> --rollback (see Migrations) before every start of odoo.service: it is a oneshot without RemainAfterExit, and odoo.service both Requires and orders After it. So:
a deploy (switch-to-configuration stops
odoo.servicewhen its unit changes and starts it again) updates exactly the modules whose code or version changed, with Odoo stopped — no worker, cron orqueue_jobrunner touches the database mid-update;a restart of the same build — a reboot,
systemctl restart odoo, a crash — costs one query;a restored dump carries another build's marker (or none), so the next start migrates it: no manual
-uafter a restore;a failed migration is rolled back to its snapshot and
odoo.servicedoes not start. New code on a schema it was not migrated to fails in ways nothing reports —queue_job's runner quietly pauses itself (database schema is outdated, -u queue_job required) while every job sits inpending— so Odoo stays down and the failure is the unit's, where monitoring sees it. Recover with a fixed deploy or a rollback of the system generation.
The first start on a database with no stored checksums updates every module once to establish the baseline; on a large database that takes minutes, hence migrate.timeout defaulting to no limit. autoInit provisions an empty database here too (--provision-if-empty); without it an empty database is skipped and Odoo starts, so a dump can be restored into it. migrate.enable = false restores the old behaviour (autoInit's -i base and update's odoo db upgrade in ExecStartPre, no detection, no snapshot).
Logging
Everything goes to the journal — no log files — in a shape a shipper reading it (Vector → Loki, journalctl -p warning) can filter without parsing text:
Fields. Every unit the module defines or turns on carries
APP_SERVICEandAPP_SITE(systemdLogExtraFields, so they are on everything journald attributes to the unit — its output, syslog messages, systemd's own lines about it).APP_SERVICEis the role:web(odoo.service),migrate(odoo-migrate),init(odoo-init),db(postgresql/postgresql-setup, withdatabase.createLocally),nginx(withnginx.enable).APP_SITEisdbName; with no pinneddbName(several databases behinddbFilter) it is left out. The Odoo units also have a fixedSyslogIdentifier—odoo,odoo-migrate,odoo-init. The same contract as frappe-nix and wordpress-nix, so one query spans all three.Priorities —
logging.journald, on by default. Odoo's stderr format is made for a terminal (2026-01-01 12:00:00,000 1234 INFO acme odoo.http: …) and lands in the journal at priority 6 whatever its level. Instead each record is written as<N>acme odoo.http: …: journald reads<N>as the syslog priority (DEBUG 7, INFO 6, WARNING 4, ERROR 3, CRITICAL 2) and strips it; asctime and pid, which the journal records anyway, and colour codes are dropped. journald splits on newlines, so every line of a record carries its<N>— a traceback stays at the priority of the error it belongs to.odoo-migrate's own progress output stays plain lines (priority 6); Odoo's warnings and errors during a migration get their priorities too. It needs the CLI package (packages.default), which applies it before handing over toodoo-bin, and only acts when stderr really is the journal stream ($JOURNAL_STREAM), so running the same package from a shell keeps Odoo's own format.Requests —
logging.accessLog, on by default. nginx logs one JSON object per request to the journal (SYSLOG_IDENTIFIER=nginx_access):time,site(the Host),method,uri,status,bytes,request_time,upstream_time,remote_addr(the real client, in socket mode too),user_agent,referer.falseturns the access log off. nginx's error log stays on stderr, same unit.PostgreSQL (with
createLocally) prefixes each line with database and role (log_line_prefix = "%d %u ", amkDefault);logging.slowQueryMs = 500;logs every statement that runs that long (log_min_duration_statement).
journalctl APP_SITE=acme -p warning # everything wrong with one site
journalctl APP_SERVICE=migrate APP_SITE=acme # its deploy-time migrations
journalctl SYSLOG_IDENTIFIER=nginx_access -o cat | jq 'select(.status >= 500)'logging.file still works, but Odoo's file and stderr handlers are mutually exclusive: with a file set, the journal only sees what is printed before Odoo's logging starts, so the module warns. Leave it null under systemd — the journal already timestamps, rotates and ships — or set logging.journald = false to keep the file without the warning. settings.syslog is left alone too (it is not stderr).
PostGIS (OCA base_geoengine): database.extensions = ps: [ ps.postgis ]; builds it into the local server and database.ensureExtensions = [ "postgis" "postgis_topology" ]; creates both in dbName as the postgres superuser. The module's pre_init_hook tries to create them itself, which only works for a superuser — the dev shell's role is one, the production role is not.
Production — OCI containers
Set odoo-nix.containers.enable = true to get packages.<sys>.container-odoo, an all-in-one image (dockerTools.buildLayeredImage) running odoo-bin with HTTP workers + gevent + cron, named <name>-odoo:latest, or <registry>/<name>-odoo:latest with containers.registry set (a trailing slash is dropped). The entrypoint synthesizes /etc/odoo/odoo.conf from environment variables, each with a default (ODOO_NIX_DB_HOST, _DB_PORT, _DB_USER, _DB_NAME, _HTTP_PORT, _GEVENT_PORT, _WORKERS, _MAX_CRON_THREADS, _PROXY_MODE, _LIST_DB, _WITHOUT_DEMO, _LOG_LEVEL, _LOG_HANDLER, _LOG_DB, _LOG_DB_LEVEL; formerly ODOO_*, see Renamed options), and merges secrets from mounted files:
/var/lib/odoo/data— persistent filestore (volume),/secrets/db_password,/secrets/admin_passwd,/secrets/*.conf— secrets,PostgreSQL is external.
Library
lib.mkFlake
Wraps flake-parts.lib.mkFlake, merging odoo-nix's own inputs (devenv, uv2nix, …) so the consumer only declares odoo-nix + nixpkgs.follows.
lib.overrides
Composable Python overlays for packages needing system libraries: lib.overrides.<package> { pkgs } is a final: prev: overlay. Every one but libsass is python-devops-nix's (lib.python.packageOverrides), and those are already applied to every project's environment; they are here for a flake that builds its own package set. Pass more via pythonOverrides.
lib.addons
The addons_path synthesis used internally; importable for nix eval testing — and tested that way: see tests/eval.nix.
Testing
nix flake check -L runs everything. odoo-nix is a library, so its checks do what a consumer does: build a real Odoo from a pinned OCB tree — ocb-18 / ocb-19, flake = false inputs in the shape OCB as a flake input prescribes — through coreSource, with the same library calls the flake-parts module makes. Checks are one derivation per assertion, so the failure names itself:
| check | what |
|---|---|
eval-addons-*, eval-odoo-conf-*, odoo-conf-render | pure tests of lib/addons.nix on a synthetic tree (tests/fixtures/addons-tree) and of lib/odoo-conf.nix rendering |
eval-*-<18|19> | per-series eval assertions: the fixture's requires-python matches lib/odoo-presets.json; builtOdoo's passthru.addonsPath / odooVersion contract |
lock-fresh-<18|19> | tests/fixtures/<series>/uv.lock still matches the pinned ocb-* input (series + install_requires) |
build-deps-<18|19> | odoo-nix-build-deps check: the fixture's managed [tool.uv.extra-build-dependencies] block is what its uv.lock derives |
eval-lock-audit-* | the stale-lock audit: each fixture's lock covers its pyproject.toml (-18, -19), and on tests/fixtures/stale-lock (18.0's lock, one dependency more) it names the gap and the fix, and no virtualenv evaluates |
builtOdoo-<18|19> | the assembled package builds — including the non-editable odoo wheel; bin/odoo --version runs on the production env; python-magic finds libmagic with no LD_LIBRARY_PATH |
odoo-init-<18|19> | -i base against an in-sandbox PostgreSQL (postgresqlTestHook, unix socket); web installed proves the second core root; and, since this is the production wrapper and environment, the dev guard rails are absent from it (no odoo_devguard on disk or in the log) |
odoo-test-<18|19> | installs the fixture addon tests/fixtures/<series>/custom/odoo_nix_fixture and runs its Odoo tests (ORM round-trip + the dev guard rails: a mail redirected with the Nix-baked port, egress refused before a packet, the guards reached their targets), asserting the stats line shows they ran |
cli-lifecycle-<18|19> | the odoo CLI end to end against an in-sandbox PostgreSQL: passthrough (--help/--version), then db provision → db provision again (idempotent migrate path) → db backup → db drop → db restore, on a database distinct from the one the other checks use |
cli-migrate-<18|19> | odoo db migrate from build to build, the way odoo-migrate.service runs it: provision records the baseline; the same build is a one-query no-op; v2 of the fixture (tests/fixtures/migrate) updates only the fixture — new column, post-migrate script, new build recorded; version drift with unchanged checksums is still migrated; v3's migration commits a change and then fails, and the database is back exactly as v2 left it; --keep prunes only after a success; an uninitialised database is skipped |
devguard | the dev guard rails without Odoo or a network (tests/test_devguard.py): settings precedence and per-command disable, the post-import hook and its fail-closed behaviour, the egress guard against real sockets (loopback and Unix pass; everything else — urllib, asyncio, create_connection — is refused before any packet), the mail transport against a stub SMTP server, and the Odoo-facing guards against stand-in modules |
devguard-baked | the package lib/devguard.nix builds: Nix-baked settings actually reach the guard |
backups | python-devops-nix's backup fetch layer, which the CLI imports in-process: the base's own tests (lib.backups.check), on each interpreter a preset names |
backup-layout | odoo-nix's side of it (lib/odoo_nix_cli/backups.py, backup_layout.py, tests/test_backups.py), without Odoo or a network, on each preset's interpreter: only real backups are listed, --at in ISO 8601 and in odoo-nix's naming, fetching an older backup keeps it, odoo-nix's names (ODOO_NIX_*, the odoonix mc alias) |
eval-secrets-* | odoo-nix.secrets (python-devops-nix's lib.secrets.mkModule) through lib.mkFlake: the odoo_ variables, backup-access encrypted to the host keys by default, a declared secret with no .age file yet reported but left out of agenix-shell, lib.secrets and its lib.odooSecrets alias. The toolkit itself (edit-secret, rekey-secrets, check-secrets, agecheck) is tested in python-devops-nix |
cli-remote-restore-<18|19> | odoo db restore from a directory store: --list; the newest backup restored neutralized and migrated; an existing database overwritten, refused with --no-force, --force a warned no-op; --no-neutralize, --no-migrate and ODOO_NIX_RESTORE_MIGRATE=0; --files and the deprecated --attachments; refused without the devguard unless overridden or restore.requireDevguard is off; --at in ISO 8601 and odoo-nix's naming, whole or a leading part, and a non-matching or malformed --at fails; odoo project restore --no-force |
eval-restore-* | the dev shell's restore.* options reaching the CLI's environment, and restore.attachments read as restore.files |
eval-python-default-* | a consumer that sets only odooSeries gets that series' interpreter from lib/odoo-presets.json, and its container image evaluates |
eval-rename-*, eval-identity-* | each renamed option reaching its new name (and the shell's env, the packages' names), announced with a deprecation warning; the identity as devenv's name |
eval-ports-*, eval-mailpit-*, eval-watch-*, eval-ci-mode, eval-tree | the dev shell's option tree: every port from ports.offset (hashed from name, salted in a linked worktree) and read by odoo.conf, PostgreSQL, Mailpit and the env; ports.base and lib.mkForce pins; Mailpit on and started before the server, mail guard or not; watch.python on; CI mode's exports first, the submodule checkout skipped in it, and its one-line banner; no visible dev tree, no ports in settings |
scaffold | odoo-init offline (tests/odoo-init.sh, git, uv and odoo-nix-sources stood in): the generated flake sets odoo-nix.name and the database |
sources | odoo-nix-sources enter (tests/sources.sh) against file:// remotes: a fresh clone's submodules checked out once, as partial clones of their paired branch (OCB commits only) and recursively; one removed or deinitialized later reported, not brought back, in path order; a directory in the way, a failed checkout, shallow clones given history in place, the pin prefetch and pack merge; every message on stderr. The command itself is tested in python-devops-nix |
bootstrap | odoo-nix-bootstrap and templates/project/.envrc (tests/bootstrap.sh, nix stubbed): a fresh clone with a dirty tree gets its submodules from the pinned odoo-nix-sources (OCB commits only) before use flake; a lock from before sources, or none, uses the bootstrap's own; the next load runs no nix; a failing nix run, a directory that is not a git checkout and no git on PATH still load quietly. nix eval of a real project is the manual check in A fresh clone |
eval-bootstrap-lock-* | lib/bootstrap-lock.nix: the flake reference a flake.lock pins for odoo-nix, its node renamed; an input that follows another, or none, throws |
db-nocow | odoo-nix-db-nocow (python-devops-nix's tests/db-nocow.sh, run against odoo-nix's command): NOCOW arranged for a datadir initdb has yet to create and cleared from its parent after, an existing cluster reported, the stopped-server copy keeping mode 0700. Checks the real attribute on a btrfs builder, the no-op elsewhere (TMPDIR=<btrfs dir> to run it by hand; Linux only) |
nix-lint | python-devops-nix's lib.checks.nixLint: nixfmt-rs --check (what nix fmt writes), statix (statix.toml, compared with the base's copy) and deadnix over the repo's Nix files, templates/ and tests/fixtures/ excluded |
shared-config | python-devops-nix's lib.checks.sharedConfig: .editorconfig, .pre-commit-config.yaml and ruff.toml make every setting the base's copies make |
pr-policy | lib/pr_policy.py, the pull-request policy, against throwaway git repositories through the real committed and committed.toml: conventional titles and commits, odoo-nix's scopes, ! and BREAKING CHANGE refused, a Release-As major let through, merge commits skipped, Dependabot's long titles let past the subject length (and only that rule), the base rule, and a dispatch's pull-request lookup |
workflows | actionlint (with shellcheck over each run:) and zizmor offline with .github/zizmor.yml over .github/workflows: every uses: pinned by commit SHA |
journald-formatter | the CLI's journald output without Odoo (tests/test_journald.py, on each series' interpreter): level → <N>, every line of a traceback prefixed, and the full odoo → sitecustomize → patched init_logger path against a stand-in odoo.netsvc |
eval-module-* | services.odoo-nix evaluated, nothing built (tests/module-eval.nix; includes environmentFiles reaching the units): APP_SERVICE/APP_SITE and SyslogIdentifier on every unit, APP_SITE left out without dbName, the journald switch and the logging.file warning, PostgreSQL's log settings, nginx's access log |
module-nginx | services.odoo-nix's nginx/socket contract, with a stub Odoo that echoes headers; the journal fields on what the units log and nginx's JSON access log in the journal (needs KVM) |
module-odoo-<18|19> | services.odoo-nix with the real CLI package: odoo-migrate provisions the empty database before odoo.service starts, /web/login via nginx and directly, version_info; root's odoo db backup runs as the service user with its tools; with nginx.xSendfile, an attachment comes through nginx while Odoo's own response is empty and /web/filestore/ is a 404 from outside; a restart migrates nothing; deploying v2 (a specialisation) migrates the changed module; deploying v3 fails the migration, rolls it back and leaves odoo.service down; redeploying v2 recovers; Odoo's records in the journal at their priority with no asctime/pid (workers and the gevent process included), a traceback at the error's priority on every line (needs KVM) |
Everything that runs Odoo or a VM is Linux-only; eval checks and lock-fresh run on every system. nix develop gives a shell for working on odoo-nix itself (uv, nixfmt-rs, odoo-nix-relock); consumers get their devenv shell from the flake-parts module instead.
The fixtures are the smallest consumer-shaped workspace: a hand-written pyproject.toml (dependencies = ["odoo"], OCB as the path source odoo/ exactly as oca_sources.py emits it, freezegun + websocket-client in the dev group), a committed uv.lock, and one custom addon. There is no odoo/ in the tree — the Nix build gets it from the ocb-* input through coreSource, and the test run uses testPythonEnv (packages.odooTestEnv in a consumer): the production wheels plus the dev group, non-editable, so it runs in the sandbox.
Re-locking
The weekly dependabot PR bumps ocb-18 / ocb-19 (their own group, separate from nixpkgs & co). uv.lock does not record OCB's revision, only what uv derived from it, so a bump is only stale when OCB's setup.py install_requires (or its series) changed — then lock-fresh-<major> fails and prints the fix:
nix run .#relock # or: nix run .#relock -- --upgrade
git add tests/fixtures/*/uv.lock tests/fixtures/*/pyproject.tomlIt runs uv lock in each fixture with the series' interpreter from lib/odoo-presets.json, then syncs the sdist build-dep block with odoo-nix-build-deps (lib/build-deps.nix) — the tail of what odoo project update runs in a consumer, including the lock-time shadow of the store path (lib/core-shadow.sh) that coreSource needs; tests/fixtures/<series>/odoo (gitignored) is left pointing at the pinned tree afterwards.
odoo-ls's own weekly dependabot PR (odoo-ls-src / odoo-ls-typeshed, their own group) is different: odoo-ls does not commit a Cargo.lock upstream, so odoo-nix vendors one at lib/odoo-ls-Cargo.lock. A bump needs it refreshed, or nix build .#odoo-ls fails outright (the lighter-weight equivalent of lock-fresh-<major> for this input):
nix run .#relock-odoo-ls
git add lib/odoo-ls-Cargo.lockThe OCA catalog
data/oca-modules.json is the bundled catalog (every OCA module across the cloned repos, with repo, version, application, installable, depends, summary, …) that powers the picker and dependency resolver. It covers 19.0 and 18.0 — one record per (repo, module, series).
Refreshing it is two commands. sync-oca-repos.sh keeps one bare mirror per OCA repo under the gitignored data/.cache/, fetching each series as a shallow branch (repos without that branch are skipped); extract_manifests.py then reads the manifests straight out of the git objects — no working trees:
data/sync-oca-repos.sh # or: data/sync-oca-repos.sh 19.0
data/extract_manifests.py # rewrites data/oca-modules.jsonBoth default to the series list in lib/odoo-presets.json; keep the three in sync when a series is added or dropped. A manifest whose version disagrees with the branch it was read from is skipped, since every consumer filters on the version prefix.
data/oca-bundles.json is a separate, hand-maintained file defining the named module bundles used by odoo module add-bundle (see Bundles).
The catalog only powers the OCA-browsing path — odoo module add <git-url> (see Adding a third-party module repo) bypasses it entirely, deriving everything (branch, module names) from the repo itself.
Repository layout
flake.nix # inputs; flakeModules / nixosModules / lib / packages
modules/
flake-module.nix # imports devenv.flakeModule + devenv.nix + containers.nix
devenv.nix # perSystem.odoo-nix options + dev shell
containers.nix # dockerTools OCI image
nixos.nix # services.odoo-nix
secrets.nix # odoo-nix.secrets: python-devops-nix's lib.secrets.mkModule (agenix + agenix-shell)
addons/
dev_mailcatch/ # deprecated no-op stub (the catch-all is odoo_devguard's `mail` guard now)
lib/
addons.nix # addons_path synthesis (the keystone)
odoo-conf.nix # odoo.conf INI synthesis
python.nix # uv2nix Python env
odoo.nix # builtOdoo assembly
cli.nix # wraps builtOdoo (or the live dev checkout) with the odoo CLI
cli-package.nix # the odoo_nix_cli package, with python-devops-nix's _hook.py and _backups.py vendored
cli-scripts.nix # bash helpers `odoo module add[-bundle]`/`project update` delegate to
odoo_nix_cli/ # the odoo CLI itself (Python: click + rich); backup_layout.py is its backup naming
devguard/odoo_devguard/ # _product.py + Odoo guards (mail, jobs, objectstore, backups, webhooks, iap) over python-devops-nix's core; dev virtualenv only
devguard.nix # builds that package with a project's settings baked in (_baked.json)
odoo-ls.nix # odoo-ls (language server) package builder
odoo-ls-Cargo.lock # vendored lockfile (odoo-ls does not commit one upstream)
overrides.nix # lib.overrides: python-devops-nix's shared package fixes, and libsass
build-deps.nix # odoo-nix-build-deps: python-devops-nix's sdist build-dep sync
core-shadow.sh # writable lock-time shadow of a store-resident OCB (coreSource)
oca-lib.sh # OCA repo resolver + module picker (shell)
oca_sources.py # generate uv path-sources (deps + sources blocks)
init.nix / odoo-init.sh # the scaffolder
odoo-presets.json # series → python / branch
pr_policy.py # the pr-policy check: conventional commits, no `!` or BREAKING CHANGE
shell-tools.nix # odoo-nix-sources, -bootstrap, -db-nocow, -banner: python-devops-nix's lib.sources, lib.dbNocow, lib.banner
bootstrap.sh # odoo-nix-bootstrap: a fresh clone's submodules, before the project's flake is evaluated
bootstrap-lock.nix # the odoo-nix a project's flake.lock pins, read without evaluating the project
data/
oca-modules.json # vendored OCA catalog (+ sync/extract scripts)
oca-bundles.json # hand-maintained "must-have" module bundles
.github/workflows/ # check (flake), pr-policy, release (release-please, release-N), docs, Dependabot auto-merge
release-please-config.json # release-please: `simple`, version.txt, vX.Y.Z tags (.release-please-manifest.json)
committed.toml # the conventional-commit rules pr-policy checks
templates/project/ # scaffolder template (thin flake + pyproject + README)
tests/
eval.nix # pure assertions over lib/addons.nix + lib/odoo-conf.nix
series.nix # per-series real-Odoo checks (builtOdoo, odoo-init, odoo-test, cli-lifecycle, cli-migrate, module-odoo)
module-eval.nix # evaluation assertions over services.odoo-nix (journal fields, log settings)
module-nginx.nix # NixOS VM test: nginx/socket contract (stub Odoo)
module-odoo.nix # NixOS VM test: services.odoo-nix with the real builtOdoo
test_journald.py # the CLI's journald formatter + init_logger patch, without Odoo
test_backups.py # odoo-nix's backup layout and names, without Odoo or a network
secrets-module.nix # odoo-nix.secrets evaluated through lib.mkFlake
test_devguard.py # the dev guard rails, without Odoo or a network
test_pr_policy.py # lib/pr_policy.py against throwaway git repositories
bootstrap.sh # odoo-nix-bootstrap + the template .envrc on a fresh clone (nix stubbed)
lock_fresh.py # uv.lock ↔ pinned OCB consistency
relock.nix # `nix run .#relock`
relock-odoo-ls.nix # `nix run .#relock-odoo-ls`
fixtures/<series>/ # pyproject.toml + uv.lock + custom/odoo_nix_fixture
fixtures/addons-tree*/ # synthetic trees for eval.nix
fixtures/secrets/ # a ciphertext for the secrets module testsLicense
MIT, see LICENSE.
The dev_mailcatch stub (addons/) and the test fixture addons (tests/fixtures/) are Odoo modules and declare LGPL-3 in their own __manifest__.py.