Jump to
↵select↑↓navigateescclose

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 core

  • auto-synthesized addons_path — derived from the folders actually present, so it can never drift out of sync with your submodules

  • a 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 builtOdoo package — the assembled, deployable Odoo tree

  • a single-instance NixOS module (services.odoo-nix) with secret-safe config synthesis

  • an OCI container image (dockerTools)

  • a single odoo CLI — 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 real odoo-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 shell

OCA 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:

SeriesPythonOCB / OCA branch
18.0python31118.0
19.0python31219.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-eval

Past 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

OutputDescription
flakeModules.defaultthe flake-parts module (devenv shell + containers + the odoo-nix option namespace)
nixosModules.defaultthe standalone services.odoo-nix NixOS module
lib.mkFlakeflake-parts.lib.mkFlake wrapper that merges odoo-nix's own inputs
lib.overridescomposable 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.addonsthe addons_path synthesis function, for testing / advanced use
lib.odoo-lsthe odoo-ls (Odoo language server) package builder
packages..odoo-init / .defaultthe scaffolder executable
packages..odoo-lsodoo-ls, pinned to this repo's odoo-ls-src/odoo-ls-typeshed inputs
packages..sources, apps..sourcesodoo-nix-sources, the dev shell's submodule checkout, outside it: nix run github:Avunu/odoo-nix#sources -- enter --treeless odoo ROOT
packages..bootstrap, apps..bootstrapodoo-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..odoo-init / .defaultnix run entry point
apps..relock-odoo-lsnix 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

OptionDefaultDescription
enablefalseenable 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)
coreSourcenullOCB 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)
pythonthe series' preset (lib/odoo-presets.json: python311 for 18.0, python312 for 19.0)interpreter (match the series)
nodejspkgs.nodejs_22Node.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.enabletrueinstall the dev guard rails into the dev virtualenv — turning it off also makes odoo db restore refuse to run
devguard.egress.enable / devguard.egress.allowHoststrue / [ ]refuse every non-loopback connection from Odoo's Python; hostnames (*.example.com), IPs or CIDRs to let through
devguard.mail.enabletrueredirect ALL outgoing email to the local Mailpit catcher; block IMAP/POP3 (was mailcatch.enable)
mailpit.enabletruerun Mailpit, before the server, whether or not the mail guard is on
mailpit.host / mailpit.port"127.0.0.1" / ports.smtpcatcher SMTP endpoint (drives Mailpit and the mail guard; was devguard.mail.host/port, and mailcatch.* before that)
mailpit.httpPortports.mailUiMailpit web UI port (was devguard.mail.httpPort)
ports.offseta hash of namethe offset every TCP port is derived from, 0 to 899 (see Ports); ODOO_NIX_PORT_OFFSET overrides it for one shell
ports.worktreeSalttruea linked worktree (git worktree add) hashes name@<path>, so it gets its own ports
ports.basenullpin the HTTP port alone
ports.http / gevent / db / smtp / mailUi10000 / 11000 / 12000 / 13000 / 14000 + ports.offsetthe 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.extraBlockedtrue / [ ]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.enabletruerefuse remote fs.storage; skip remote auto_backup jobs; no-op webhook actions; fail IAP calls readably
settings.dbHost/dbUser/dbPassword/dbName127.0.0.1 / odoo / False / odoo_devDB 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.workers0worker 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" ] (log_handler)
settings.logging.db/dbLevelfalse / "warning"mirror logs into a database (log_db/log_db_level): true = the request's own database (%d), a string = that database
settings.logging.filenullwrite logs to this file instead of stderr (logfile); production logging is services.odoo-nix's
watch.pythontruerestart 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.withoutDemofalseskip demo data for every module (without_demo = True)
postgres.extensions_: [ ]extensions built into the dev PostgreSQL, e.g. ps: [ ps.postgis ] (base_geoengine)
postgres.noCowtrueon 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.enabletrueexpose the env to editors: ./.venv symlink + merged odoo analysis root
ide.vscodeSettingstrueseed .vscode/settings.json when absent (never overwrites)
ide.languageServer.enabletruerun odoo-ls: odoo_ls_server on PATH + symlinked ./odools.toml — see Odoo language server
ide.languageServer.packageodoo-ls pinned to odoo-ls-src/odoo-ls-typeshedthe odoo_ls_server package to use
restore.enablesecrets.backupAccess.enabledecrypt 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.filesfalsealso 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.neutralizetrueneutralize databases restored from the remote store
restore.migratetrueodoo db migrate the restored database to this checkout's modules (--no-migrate)
restore.requireDevguardtruerefuse 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.registryfalse / ""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.

OldNew
projectNamename, which also sets devenv's own name (lib.mkDefault)
odooConf.*settings.*, as in services.odoo-nix; every option under it keeps its name
mailcatch.enabledevguard.mail.enable
mailcatch.{host,port,httpPort}, devguard.mail.{host,port,httpPort}mailpit.{host,port,httpPort}
dev.autoReloadwatch.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.attachmentsrestore.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:

OldNew
ODOO_DB (the odoo CLI's default database)ODOO_NIX_DB
ODOO_TEST_ALLOW_SKIPODOO_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 via odoo.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 watchdog

What it does and does not cover:

  • Python — every addons_path root is watched recursively: odoo/odoo/addons, odoo/addons, each modules/<repo>, and custom/. 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=xml drops the ORM cache on ir.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.

  • workers must be 0 (the default). The watcher only runs on the threaded server; the dev shell warns if you set workers and leave watch.python on.

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.

ArtifactPurpose
./.venv → the Nix-built dev envthe venv layout every editor probes for at the workspace root
.devenv/state/pythonpatha 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:

  1. uv2nix installs every OCA/custom module editable, through .pth files whose body is sys.path.append(os.path.expandvars(…)). Only a running interpreter executes those.

  2. odoo.addons is a pkgutil namespace that Odoo extends from addons_path at startup — and the bulk of the standard addons (sale, portal, mail, …) live in <coreSrc>/addons, outside the odoo package. 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.json recommends Odoo.odoo, prompting VS Code to offer installing it.

  • .vscode/settings.json gets "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:

GuardWhat it doesSettings
egressthe 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 Cegress.allowHosts
mailevery 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 refusedmail.enable; the catcher is the mailpit tree
jobsskips denylisted scheduled actions (crons) by exact external id, even if switched back on; its log lines read odoo_devguard[crons]jobs.extraBlocked
objectstorefs.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
backupsauto_backup jobs to SFTP/FS Storage are skipped, and so is their retention pass (which would delete production's backups)
webhooks, iapoutbound 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.

CommandAction
odoo db listlist 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 -u leaves the schema silently behind it;

  • version drift: ir_module_module.latest_version is 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 --list

Three pieces make this work, and they share one set of variables:

VariableMeaning
BACKUPS_URLS3 endpoint
BACKUPS_ACCESS_KEY, BACKUPS_SECRET_KEYcredentials
BACKUPS_BUCKETbucket
BACKUPS_PREFIXoptional 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 name

Branch 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 bundles

Add 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, odoo for core depends, and external_dependencies.python as real PyPI requirements.

  • OCB itself resolves as an odoo path dependency — its setup.py carries Odoo's own requirements, so uv resolves those too (replacing any requirements.txt translation).

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 from modules.txt as odoo-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 /odoo to .gitignore — so odoo-bin, the scripts and the editor mirror all see OCB where the submodule layout has it, and pyproject.toml/uv.lock are unchanged (odoo stays the path source odoo/). uv lock (via odoo project update / odoo module add) is the one thing that cannot go through the link directly — setuptools writes odoo.egg-info into 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 odoo wheel from the store path (uv2nix would otherwise look for odoo/ in the flake's copy of the workspace, where the gitignored symlink does not exist);

  • addons_path — the dev odoo.conf, lib.addons' addonsPathFor used by checks, and builtOdoo — carries the two core roots as absolute store paths, and builtOdoo links 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.service when its unit changes and starts it again) updates exactly the modules whose code or version changed, with Odoo stopped — no worker, cron or queue_job runner 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 -u after a restore;

  • a failed migration is rolled back to its snapshot and odoo.service does 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 in pending — 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_SERVICE and APP_SITE (systemd LogExtraFields, so they are on everything journald attributes to the unit — its output, syslog messages, systemd's own lines about it). APP_SERVICE is the role: web (odoo.service), migrate (odoo-migrate), init (odoo-init), db (postgresql/postgresql-setup, with database.createLocally), nginx (with nginx.enable). APP_SITE is dbName; with no pinned dbName (several databases behind dbFilter) it is left out. The Odoo units also have a fixed SyslogIdentifier — 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 to odoo-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. false turns 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 ", a mkDefault); 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:

checkwhat
eval-addons-*, eval-odoo-conf-*, odoo-conf-renderpure 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
devguardthe 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-bakedthe package lib/devguard.nix builds: Nix-baked settings actually reach the guard
backupspython-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-layoutodoo-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-treethe 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
scaffoldodoo-init offline (tests/odoo-init.sh, git, uv and odoo-nix-sources stood in): the generated flake sets odoo-nix.name and the database
sourcesodoo-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
bootstrapodoo-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-nocowodoo-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-lintpython-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-configpython-devops-nix's lib.checks.sharedConfig: .editorconfig, .pre-commit-config.yaml and ruff.toml make every setting the base's copies make
pr-policylib/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
workflowsactionlint (with shellcheck over each run:) and zizmor offline with .github/zizmor.yml over .github/workflows: every uses: pinned by commit SHA
journald-formatterthe 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-nginxservices.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.toml

It 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.lock

The 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.json

Both 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 tests

License

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.