Conventions
How frappe-nix and odoo-nix name and do the jobs they both do, and where each product stands on each decision.
On this page
How frappe-nix and odoo-nix name and do the jobs they both do. Where the two products do the same job, they do it the same way and under the same names. This page records one decision per job and where each product stands on it.
It is the record of what the products do on their main branches as of 2026-10-22. odoo-nix v1.1.0 shipped all of it; frappe-nix v1.2.0 and v1.2.1 shipped most of it, and the rest lands in frappe-nix's next release. It was first drafted on 2026-10-16, and what is left is tracked in each repository's issues. A pull request that changes a convention, or a product's adoption of one, updates this page.
How to read this
Each section gives:
Convention: what both products do.
Adopted: where each product stands, with the pull request that adopted it where that helps.
frappe-nix#Nandodoo-nix#Nare the products' pull requests;#Nis this repository's. A linked number is an open issue. A pull request still open on 2026-10-22 is marked pending.Status: decided (both products follow it, or are bound to), partial (one product, or part of the convention, is not there yet; the Adopted line says what), or deferred to 2.0.0 (shipped opt-in through 1.x and the default from the product's 2.0.0).
Check: what checks the convention mechanically. A check this repository ships is named. Where the base does not ship one yet, the line says so and points to #19, which tracks the base's conformance checks, and names the product's own tests where they cover it.
<P> stands for the product's environment prefix (FRAPPE, ODOO), and <p> for its lower-case name (frappe, odoo).
Renames and the 1.x line
Both products moved to the names here, even where that was a breaking change. Every rename ships with a migration note in the product's changelog and upgrading page. Every renamed option, flag and environment variable keeps an alias that warns until the product's next major release, a deliberate Release-As: 2.0.0, so a consumer that follows release-1 never loses a name it set. In flake-parts perSystem and in odoo-nix's devenv module, the alias goes on the product's internal deprecations list, folded into lib.warn, because mkRenamedOptionModule cannot warn there (odoo-nix's devenv module renames with lib.doRename and warn = false beside that list); the NixOS modules use mkRenamedOptionModule.
A change that alters a running deployment without anyone asking for it ships opt-in through 1.x and becomes the default in 2.0.0: unit hardening, non-root containers and odoo-nix's role image name. frappe-nix#182 tracks frappe-nix's flips; odoo-nix's ship in its own 2.0.0.
Naming rule
Option names follow one rule.
Structure follows nixpkgs. Use
enable,package,userandgroup,dataDirorstateDir,environmentFilesandopenFirewall. Usesettingsfor a freeform attribute set rendered into the framework's config file (RFC 42). Key multi-tenant modules by hostname, as inservices.wordpress.sites.<host>andservices.nginx.virtualHosts.<host>.Names for things frappe-nix or odoo-nix invented are shared. That covers the project identity, ports, guards, the mail catcher and the restore flags. Both products use the same word.
Names that mirror a framework config key use that framework's word, in camelCase. Frappe's
default_siteisdefaultSite; Odoo'sdb_nameisdbName. Two frameworks with different concepts keep different names.
nixpkgs is not consistent about the option for an app's public hostname (nextcloud hostName, discourse hostname, odoo domain, mastodon localDomain, mediawiki url). Part 3 settles it: each product keeps the shape its nixpkgs neighbour uses, if there is one.
Environment variables
Tool-owned variables
Convention: every environment variable a product's own tools read or export is
<P>_NIX_*:FRAPPE_NIX_*andODOO_NIX_*. Both export<P>_NIX_DEVGUARD. devguard's settings are<P>_DEVGUARD_*(see The core and the product's package).Adopted:
frappe-nix (frappe-nix#123) exports
FRAPPE_NIX_DEVGUARDand readsFRAPPE_NIX_RESTORE_ALLOW_UNGUARDED,FRAPPE_NIX_BACKUP_SOURCE,FRAPPE_NIX_MCandFRAPPE_NIX_BACKUP_CACHE. The old names (FRAPPE_RESTORE_ALLOW_UNGUARDED,FRAPPE_BACKUP_SOURCE,FRAPPE_BACKUP_MC,FRAPPE_BACKUP_CACHE,FRAPPE_OFFLINE_MIGRATE*,FRAPPE_BENCH_UPDATE_FORCEand others) are read with a warning until 2.0.0.FRAPPE_UNIXSOCK_ENABLEDis still outside the pattern, and frappe-runtime reads its ownFRAPPE_SERVE_ASSETSandFRAPPE_WEB_THREADS.odoo-nix (odoo-nix#57) reads
ODOO_NIX_*and exportsODOO_NIX_DEVGUARD.ODOO_DB,ODOO_TEST_ALLOW_SKIP,ODOO_HTTP_PORTandODOO_GEVENT_PORTstay, with a warning, until 2.0.0.ODOO_NIX_JOURNALDis no longer read, and setting it warns.
Status: partial: frappe-nix's
FRAPPE_UNIXSOCK_ENABLED.Check: not yet in the base (#19). Such a check needs an allow-list for a framework's own variables and the aliases.
The backup fetch layer (lib.backups) reads <P>_NIX_BACKUP_SOURCE, <P>_NIX_MC and <P>_NIX_BACKUP_CACHE. Its legacyEnv argument reads an old name, with a warning, for the deprecation window. The connection itself keeps the names the backup-access secret already uses in both products, which belong to the secret's format rather than to a product: BACKUPS_URL, BACKUPS_ACCESS_KEY, BACKUPS_SECRET_KEY, BACKUPS_BUCKET and BACKUPS_PREFIX.
Secrets
Convention: both products declare their secrets with
lib.secrets.mkModuleat<p>-nix.secrets, with one option tree:enable,dir,relDir,recipients,hostRecipients,identityPaths,backupAccess.{enable,hosts},extra.<name>.{format,var,hosts}, plus frappe-nix'ssitesas a group. Shell variables are<p>_<role>(odoo_backup_access), and<p>_<role>_<unit>for a group's units (frappe_db_password_erp_example_com). The flake output isflake.lib.secretsin both. The dev-shell commands areedit-secret,rekey-secrets,check-secretsandsetup-backup-access, fromlib.secrets.tools.edit-secret,rekey-secretsandsetup-backup-accessare Python with rich prompts (#15), andcheck-secretsis anexecshim over agecheck.Adopted: frappe-nix#97 and odoo-nix#52.
flake.lib.frappeSecretsandflake.lib.odooSecretsstay as aliases (libAliases) until 2.0.0.backupAccess.hostsdefaults to false in frappe-nix and true in odoo-nix, whose server reads the same secret; that stays a product's choice of its role's default.Status: decided.
Check: the base's
lib-secrets-module,lib-secrets-schema,lib-secrets-cli,lib-secret-toolsandlib-agecheck. Adoption was shown by comparing each product's rules file and agenix-shell secret set before and after, as JSON.
Restore
bench restore (frappe-nix) and odoo db restore (odoo-nix) take the same flags and options.
Overwrite
Convention: restore overwrites an existing database by default.
--no-forcerefuses instead.Adopted: frappe-nix always did (frappe-nix#98 kept it). odoo-nix since odoo-nix#53, where
--forceis accepted as a no-op with a warning until 2.0.0.Status: decided.
Check: not yet in the base (#19). Each product's restore tests assert it (frappe-nix's
tests/bench-restore.sh).
Migrate after restore
Convention: restore runs the framework's migration afterwards, on by default.
--no-migrateandrestore.migrateturn it off.Adopted: frappe-nix always had both. odoo-nix since odoo-nix#53 (
--migrate/--no-migrate,restore.migrate).Status: decided.
Check: not yet in the base (#19).
Neutralise
Convention:
--neutralize,--no-neutralizeandrestore.neutralizein both. In frappe-nix, neutralising pauses the scheduler and mutes mail in the restored database, as defence in depth below devguard.Adopted: odoo-nix had both; its
restore.neutralizeis on in the development shell and off on a server. frappe-nix since frappe-nix#98 and #123, withrestore.neutralizeon by default; a restore from an explicit file is neutralised only with--neutralize.Status: decided.
Check: not yet in the base (#19).
Files
Convention:
--filesandrestore.files. frappe-nix keeps--private-filesas its own extra.Adopted: frappe-nix#123:
restore.filesis a bool or"none" | "private" | "all"(default"none"), since Frappe has private and public files;restore.withFilesis its alias. odoo-nix#57:--files/--no-filesandrestore.files, with--attachmentsandrestore.attachmentsas aliases until 2.0.0.Status: decided.
Check: not yet in the base (#19).
Unguarded restore
Convention: restore refuses to run without devguard, unless
<P>_NIX_RESTORE_ALLOW_UNGUARDEDis set.restore.requireDevguard(default true) in both.Adopted: frappe-nix#98 and #123; odoo-nix#53, whose command line also reads
ODOO_NIX_RESTORE_REQUIRE_DEVGUARD.Status: decided.
Check: not yet in the base (#19). The refusal is in each product's restore tests.
Point in time (--at)
Convention:
--ataccepts ISO 8601 in both, and each product's own backup naming as well. A backup's exact name wins; otherwise the value's digits are matched as a prefix of the backup'sYYYYMMDDHHMMSS, so any leading part of a date or time picks the newest backup within it (2026-10is the newest of October 2026). A trailingZis accepted; other offsets, fractions and words are refused. Times are the backup's own clock. Implemented once, inlib.backups(parse_at,pick).Adopted: both, through the base's fetch layer (frappe-nix#98, odoo-nix#53).
Status: decided.
Check: the base's
lib-backupstests the parser on every preset interpreter, and drives the command with each form.
devguard
SMTP port
Convention: the option is named
port, matching both Python settings schemas. Where it lives is in the development shell option tree:mailpit.port.Adopted: frappe-nix#124 and odoo-nix#58. frappe-nix's
devguard.mail.smtpPortanddevguard.mail.port, and odoo-nix'sdevguard.mail.port, are aliases until 2.0.0.Status: decided.
Check: not yet in the base (#19).
Job denylist
Convention:
devguard.jobs.{enable,extraBlocked}, andblocked_jobsin the Python settings. Log lines keep the framework's words (scheduler, crons). frappe-nix'sblockServerScriptsisdevguard.jobs.blockServerScripts. In the Python settings,guards.jobs.blocked_jobsis the product's built-in list (its default),guards.jobs.extra_blockedthe consumer's additions (extraBlocked, baked), and the guard reads their union, in order, assettings().blocked_jobs, which the core provides.Adopted: frappe-nix#112 and #124; odoo-nix#56 and #58.
scheduler(frappe-nix) andcrons(odoo-nix) are aliases until 2.0.0.Status: decided.
Check: not yet in the base for the option paths (#19).
lib-devguardtests the union.
Shared guard names
Convention: the guards both products have keep their names,
egress,mail,mail_stdlib,objectstore,backups,jobsandwebhooks, with the same option shape under each (enableand the same settings). Product guards sit beside them (frappe-nix'sintegrations,googleandplaid; odoo-nix'siap).Adopted: both (frappe-nix#124, odoo-nix#58).
Status: decided.
Check: not yet in the base (#19).
The core and the product's package
Convention: each product's guard package is
<p>_devguard, built bylib.devguard.mkPackage { pkgs, name, src, settings }from this repository's core (python/devguard:__init__.py,_hook.py,_settings.py,_patch.py,guards/egress.py,guards/mail_stdlib.py) and the product's own tree, and grafted into the development virtualenv only, withlib.python.withGrafts. The build fails on a file both trees have, so a product cannot shadow the core. The product's tree holds:_product.py, the contract:Name Required What ENV_PREFIXyes <P>. The settings variables are<P>_DEVGUARD_*.FRAMEWORK,PRODUCTyes What a fail-closed requirenames: "<FRAMEWORK>'s internals have moved … Update<PRODUCT>".DEFAULTSno { "guards": { <guard>: { <key>: value } } }, merged over the core's defaults key by key: the product's own guards' settings, and any core default it changes.GUARDSno The product's guard modules, by name under guards/, installed afteregressandmail_stdlib, in order.EGRESS_ALLOWno ((<guard>, <key>), …): settings lists egress allows as well asegress.allow_hosts(frappe-nix's("integrations", "allow_hosts")).log(guard, message)no An extra log sink beside stderr (frappe-nix's frappe.logger). Its exceptions are swallowed.user_error(guard, message)no Raises the framework's user-facing error ( frappe.throw, Odoo'sUserError), for_patch.user_error. Without it, or if it returns,DevGuardBlockedis raised.guards/<guard>.pyfor each guard of its own, each withNAMEandinstall(), built on the core's_hook.on_importand_patchhelpers;its own helpers, in a module of its own (
_frappe.py,_odoo.py), not in_patch.py.
The package name is not in the contract: the core reads it from the package itself, so the log prefix cannot disagree with the directory.
Settings resolve, highest first: the environment (
<P>_DEVGUARD_<GUARD>_<KEY>, plus<P>_DEVGUARD_ENABLEDand<P>_DEVGUARD_DISABLE=<guard>,…); the runtime file ($<P>_DEVGUARD_RUNTIME, else$DEVENV_RUNTIME/devguard-runtime.json), which the shell writes with the values only known once its processes start; the baked_baked.json(mkPackage'ssettings); the product's defaults; the core's. The schema is{ "enabled", "guards": { <guard>: { "enable", … } } }, in snake_case. The core readsegress.allow_hosts,mail.{host,port,http_port,pop3_enabled,pop3_port,pop3_user,pop3_password}andjobs.{blocked_jobs,extra_blocked}; every other key is the product's.What users see is fixed by the core: the
.pthfile iszzz-<p>-devguard.pth; every log line isWARNING <p>_devguard[<guard>] <message>, a guard's first is… ACTIVE -- <what it does>, and a refusal raisesDevGuardBlocked(anOSError) reading<p>_devguard[<guard>]: <message>.<P>_NIX_DEVGUARD(see Tool-owned variables) is the shell's statement that devguard is on, for the product's own tools; the guard package does not read it.Adopted: frappe-nix#112 and odoo-nix#56. The product copies of the core are deleted.
Status: decided.
Check:
lib.devguard.check { pkgs, product, interpreters }runs the core's tests against a stub product on every interpreter the product's presets name, and the merged build refuses a shadowed core file (lib-devguard,lib-devguard-package). Each product keeps a check that its production environment has no<p>_devguard.
Grafts
Convention: every package loaded at interpreter start, devguard or not, is grafted with
lib.python.withGrafts { python, env, grafts }, which writeszzz-<module, _ as ->.pthrunningimport <module>; <module>.install(). A graft that needs a post-import hook is built withlib.python.mkGraft { pkgs, name, src }, which adds the shared_hook.py; no graft carries its own copy.Adopted: frappe-nix#112 and #151 (devguard, unixsock, nodebuild, benchcli and journald); odoo-nix#56 and #73 (devguard and journald).
Status: decided.
Check: the base's
lib-devguardgrafts two packages into one virtualenv and asserts the.pthnames and that both hooks fire.
Python environment
Both products build their environment the same way: pyproject-nix, uv2nix and pyproject-build-systems (the product's inputs, not the base's), sourcePreference = "wheel", mkEditablePyprojectOverlay for development, and .pth grafts. These sections fix the pieces around that (library).
Package fixes
Convention: a fix for a package that needs a system library lives in
lib.python.overrides { pkgs }, one pyproject-nix overlay. The product composes it right after uv2nix's overlay and layers its own on top (composeManyExtensions [ pyproject-build-systems.overlays.default workspaceOverlay (lib.python.overrides { inherit pkgs; }) productOverrides ]). It covers psycopg2, python-ldap (with theldap_rlink fix), lxml, pycups, python-magic, pyvips and cairocffi, and touches only a package the set has. A build fix (headers, pkg-config, setuptools) applies only to a source build, so a wheel's derivation is left as uv2nix made it. A runtime fix (a library's store path where the package dlopens it) patches the installed files, so it reaches a wheel too. The product keeps the fixes only it needs (frappe-nix's mysqlclient, odoo-nix's OCB build andlxml_html_clean).lib.python.packageOverrides { pkgs }has the same fixes one by one, for a product that exports them to its consumers.Adopted: frappe-nix#127 (its
lib/overrides.nixstays as an alias until 2.0.0) and odoo-nix#59, which adds a consumer'sextraOverrideslast.Status: decided.
Check: the base's
lib-python-overridesapplies the set to a stub package set. Adoption was shown by each product's production environment derivation being unchanged, except for the fixes new to it.
Tool interpreter with rich
Convention: a product's own tooling (a banner, a command line) runs on
lib.python.rich { python, extraPackages ? ps: [ ] }: nixpkgs' rich, with markdown-it-py's checks off and rich's racytest_brokenpipeerrorskipped. It is never added to the application's environment.Adopted: odoo-nix#59 (its command line, scaffolder and banner). frappe-nix's banner runs on it through
lib.banner, but frappe-nix-tools, its command-line package, is still built on nixpkgs' plain rich (lib/standards/package.nix).Status: partial: frappe-nix's tooling (frappe-nix#179).
Check: the base's
lib-python-richbuilds it on every preset interpreter, with rich's suite.
Stale lock
Convention: every virtualenv is wrapped in
(lib.python.lockAudit { workspaceRoot, hasPackage = name: pythonSet ? ${name}; label = "<p>-nix"; lock ? "uv.lock"; relock ? …; }).assertCurrent, so a declared dependency missing from the lock fails evaluation with<p>-nix: uv.lock does not cover every declared dependency, a line per gap naming the member and the requirement, and how to re-lock, instead of uv2nix'sattribute '<name>' missing.Adopted: frappe-nix#127 and odoo-nix#59. In odoo-nix the audit catches a root dependency missing from
uv.lock, which otherwise builds silently without it; an OCA addon's owndynamicrequirements are out of its reach.Status: decided.
Check: the base's
lib-python-lock-audit(an eval check).
Build dependencies
Convention:
[tool.uv.extra-build-dependencies]is derived fromuv.lock, not kept by hand. Every package the lock has no wheel for getssetuptools; the product'salwaysadds entries the lock cannot show (a package that is sdist-only elsewhere, a backend other than setuptools). The entries sit between# >>> <p>-nix: sdist build deps (managed) >>>and# <<< <p>-nix: sdist build deps (managed) <<<, and the relock command runslib.python.buildDeps { pkgs, label = "<p>-nix", always }(<p>-nix-build-deps update) afteruv lock, then locks again.<p>-nix-build-deps checkfails when the block is out of date.Adopted: frappe-nix#127 and odoo-nix#59.
Status: decided.
Check: the base's
lib-python-build-depsruns the tests against both products' lock shapes on every preset interpreter.
Thread caps
Convention: the application's environment sets
lib.python.threadCaps(OPENBLAS_NUM_THREADS,OMP_NUM_THREADS,MKL_NUM_THREADSandNUMEXPR_NUM_THREADS, each1) everywhere it runs: the development shell, the NixOS services and the containers.Adopted: frappe-nix#127 and odoo-nix#59, in all three places.
Status: decided.
Check: none.
Scaffolder
frappe-nix's frappe-init and odoo-nix's odoo-init, both Python since frappe-nix#108 and odoo-nix#71.
Version flag
Convention:
--version. The old names stay as aliases until 2.0.0.Adopted: frappe-nix#108 (
--frappe-versionaliased) and odoo-nix#71 (--seriesread with a warning).Status: decided.
Check: not yet in the base (#19).
Flags
Convention: both scaffolders have
--dry-run,--skip-lock,-y/--yes,--commit,--force,--name, and migrating an existing tree. frappe-nix's--siteis--default-site.Adopted: frappe-nix#108 (
--sitealiased until 2.0.0) and odoo-nix#71 (with--migrate,--initand--db).Status: decided.
Check: not yet in the base (#19).
Exit codes
Convention: one table in both: 0 done (or a dry run finished); 1 an error with no more specific code; 2 an initialisation into a directory that is not empty, without
--force; 4 aflake.nix(or, in odoo-nix, apyproject.toml) that is not the product's is in the way; 5 the framework version is missing, unknown or unsupported; 6 the directory is not the shape the mode needs, or the confirmation was declined; 7 there is nothing to migrate; 8 the tree is inside another git repository. There is no 3. odoo-nix adds 130 for an interrupt.Adopted: frappe-nix#108 and odoo-nix#71. Each product's scaffolder reference documents its table.
Status: decided.
Check: partly: each product's scaffolder tests assert the code for each failure they reach.
Presets file
Convention: one schema:
{label, branch, python, requiresPython}, withnodejsandoverrideDependenciesoptional.Adopted: frappe-nix has
{label, branch, python, nodejs, requiresPython, overrideDependencies}. odoo-nix#71 has{label, branch, python, requiresPython}, for 18.0, 19.0 and 20.0 (odoo-nix#75).Status: decided.
Check: not yet in the base.
Seeds
Convention: both scaffolders seed
secrets/README.mdand print a warnings summary.Adopted: frappe-nix#108 and odoo-nix#71.
Status: decided.
Check: each product's dry-run tests list the seeded files.
Next steps
Convention: the same closing wording in both, naming each product's own command: restore when secrets are declared, otherwise provision.
Adopted: odoo-nix#71 suggests
odoo db restore <db>orodoo db provision. frappe-nix suggestsbench restoreorbench provision(frappe-nix#166).Status: decided.
Check: no; wording is reviewed.
Flake
Systems
Convention:
x86_64-linux,aarch64-linuxandaarch64-darwin. Nox86_64-darwin, which nixpkgs 26.11 dropped.Adopted: both (frappe-nix#123).
Status: decided.
Check: not yet in the base.
Development shell
The leaf pieces inside both devenv modules (library), adopted in frappe-nix#130 and odoo-nix#62. The modules themselves stay in the products.
Ports
Convention: every TCP port the shell binds is a base the product picks plus one offset, 0 to 899, a hash of the project's
name(sha256(name)[0:4] mod 900), taken fromlib.ports. A linked worktree (.gita file) salts the hash with its path whenports.worktreeSaltis on.<P>_NIX_PORT_OFFSETpicks another offset for one shell and wins overports.offset; a value outside 0 to 899 is ignored with a warning. The devenv module declares itsportstree withlib.ports.options(see The development shell option tree); the bases stay the product's (frappe-nix: web 8000, MariaDB 3306, socket.io 9000, Mailpit 19000 to 21000; odoo-nix: HTTP 10000, gevent 11000, PostgreSQL 12000, Mailpit 13000 and 14000), clear of each other so a bench and a project never meet.Runtime leases: what
lib.portsgives is the preferred port. devenv's port allocator is the identity under the flake integration, so two copies of one project (CI checkouts, which are never linked worktrees, or two clones) prefer the same ports, and nothing evaluated can move a taken one. A product may lease its ports whendevenv upstarts: a first process claims each preferred port host-wide for the project's lifetime and moves past a taken one, or refuses it whereports.strictsays so (<P>_NIX_STRICT_PORTSat run time), and writes the ports in force to$DEVENV_RUNTIME/<p>-nix/ports.json. Consumers then read that file, not the evaluated values. A product that commits a port (frappe-nix's bench mode, whosewebserver_portis incommon_site_config.json) is strict by default.Adopted: both take
lib.ports(frappe-nix#130; odoo-nix#58 and #62, which ended its fixed ports). frappe-nix leases its nginx and Mailpit ports atdevenv up(frappe-nix#144): app mode moves, bench mode refuses. odoo-nix uses the evaluated ports as they are.Status: decided. Leasing is a product's choice.
Check: the base's
lib-ports, which holds both products' ports as golden values.
CI mode
Convention:
<P>_NIX_CIset to anything but empty,0orfalseputs the shell in CI mode, read when enterShell runs:PC_TUI_ENABLED=0andDEVENV_IN_DIRENV_SHELL=trueare exported first, the interactive steps (node installs, the sources report) are skipped, and the banner is one line,<p>-nix: CI mode (<name>[, <details>]). Snippets fromlib.ciMode { label, envPrefix }.Adopted: frappe-nix#130 (
FRAPPE_NIX_CI) and odoo-nix#62 (ODOO_NIX_CI).Status: decided.
Check: the base's
lib-ci-moderuns the snippets; a product's own check asserts its enterShell uses them.
Sources
Convention: source repositories are git submodules, and one command handles them,
lib.sources.command(<p>-nix-sources). Shell entry runsenter: a submodule this clone has never set up is checked out once, as a partial clone (--filter=blob:none, ortree:0for a path the product names treeless) of the branch.gitmodulespairs it with and no other (--single-branch), falling back to the remote's default branch; one set up before and now missing is reported, never brought back. Every entry also fetches a pin a partial clone lacks before git asks for it lazily, merges a partial clone's packs geometrically, setsfetch.recurseSubmodules=false, and gives a shallow clone its paired branch's history in place. The scaffolder's first clone isclone(orclone(), imported). Messages are<p>-nix: …on stderr, and entry always exits 0. The product passes its scope (frappe-nix[ "apps" ], odoo-nix every submodule), whether checkouts recurse (odoo-nix), its treeless paths (odoo-nix's core), and the update and remove commands its hints name.Adopted: frappe-nix#130 and #131, and odoo-nix#62. Both products' bash copies are deleted.
Status: decided.
Check: the base's
lib-sourcesruns one suite in both products' shapes on every preset interpreter.
Database copy-on-write
Convention: on btrfs, the development database's datadir has copy-on-write off (
chattr +C), fromlib.dbNocow { pkgs, label, strategy, server }(<p>-nix-db-nocow).prepareruns on shell entry before the server starts: withstrategy = "datadir"(MariaDB) it creates and marks the directory; with"parent"(PostgreSQL, whose initdb runs only when the datadir is missing) it marks the parent until initdb has made the datadir, then clears it. A datadir already holding data is reported with the command that fixes it,migrate, which refuses while the server runs (server = "cmdline", a process with--datadir=<dir>;"pidfile", a livepostmaster.pid). Off btrfs both are silent no-ops, andmigratesays so before looking for a server. Linux only.Adopted: frappe-nix#130 (
datadir) and odoo-nix#62 (parent).Status: decided.
Check: the base's
lib-db-nocowin both strategies, on whatever filesystem the builder has, and by hand on the other (TMPDIR=<dir> bash tests/db-nocow.sh …).
Banner
Convention: the shell's banner is
lib.banner.command, rendered with rich (onlib.python.rich, never the application's environment) from one JSON spec the product writes at evaluation:title,commands,urls,facts,guard(enable,egress.{enable,allow},mail.{enable,url,incoming}) andnotes. A fact known only when the shell starts is an@name@placeholder filled by--var name=value(lib.banner.script'svars). Nothing in the spec is rich markup. The devguard lines read the same in both:devguard OFF — nothing stops a restored database from reaching production,Egress: outbound connections blocked (allowed: …),Mail: ALL outgoing email → Mailpit (…).Adopted: frappe-nix#130 and odoo-nix#62.
Status: decided.
Check: the base's
lib-banner.
Command line
Command layout
Convention: every command lives under the product's umbrella command,
benchorodoo. frappe-nix'sprovision-siteisbench provision, andprovision-sitestays as an alias until 2.0.0.Adopted: odoo-nix has everything under
odoo(db,module,project). frappe-nix hasbench provisionunderbench(frappe-nix#166), withprovision-sitekept as a deprecated alias until 2.0.0.Status: decided.
Check: not yet in the base.
Source management
Convention: add and remove a source repository in both.
Adopted: frappe-nix has
bench get-app,bench remove-app,bench new-appandbench rename-app(frappe-nix#131). odoo-nix hasodoo module add,odoo module add-bundleandodoo module remove(odoo-nix#72).Status: decided.
Check: not yet in the base.
Relock
Convention: one consumer-facing relock command in both, run from the shell. A product also offers it as a flake app (
nix run .#relock) for a project whose shell cannot evaluate because its lock is stale.Adopted: frappe-nix#150:
relockin the shell andnix run .#relockare one program (--uv-only,--node-locks);update-depsandbench updatecall it. odoo-nix#82:odoo project relock, with the workspace lock in Python. odoo-nix's.#relockre-locks its own test fixtures only, so a consumer has no flake app yet (odoo-nix#95).Status: partial: odoo-nix's flake app.
Check: not yet in the base.
Production
The pieces in the NixOS modules (library), adopted in frappe-nix#151 and odoo-nix#73.
Migrate on deploy
Convention: shared: the
migrate.{enable,snapshot,rollbackOnFailure,snapshotRetention}options (defaultstrue,true,true,3), the snapshot flow, and verifying the snapshot before migrating. Product-specific: what is left running after a failed migrate. frappe-nix keeps serving in Frappe's native maintenance mode. Odoo has no maintenance mode, and serving a half-migrated schema is not safe, so odoo-nix stops the service.The options are
migrate = lib.nixos.migrate.options { what, dumpTool, where };in the product's option tree; a product adds its own beside them (frappe-nix'smaintenanceModeandoffline, odoo-nix'sfullandtimeout). The unit'sExecStartislib.getExe (lib.nixos.migrate.script { ... }), which runs python/migrate.py's flow for one target with the product's executables:Step Argument Contract check check(optional)Exit 0 to migrate, 100 when there is nothing to do (it prints why), anything else is an error before anything changed. frappe-nix: the build marker and the uninstalled-site guard. snapshot snapshotDir,prefix(defaultpremigrate-<target>),extension,dumpdumpwrites to stdout; the file is<prefix>-YYYYmmdd-HHMMSS<extension>, 0600 from its first byte, in a directory made and kept 0700. A failed or empty dump is removed and stops the deploy before it migrates.verify verifyverify SNAPSHOTexits 0 when the dump is whole. One that does not is removed and stops the deploy.lib.nixos.migrate.verifiers.mysqldump { pkgs, compressed ? true }(gzip's check and mysqldump's closing-- Dump completedline) and.pgRestoreList { pkgs, postgresql }(pg_restore --list).migrate migrateThe migration. frappe-nix's turns maintenance mode on first. success onSuccess(optional)After a migration that exited 0; a failure here is a warning. Then the newest snapshotRetentionsnapshots are kept. frappe-nix: maintenance mode off, write the marker.rollback restoreWith rollbackOnFailure:restore SNAPSHOTputs the database back as the dump has it (frappe-nix: drop every table, then load the dump; odoo-nix: drop and recreate the database, thenpg_restore).failure onFailure(optional)After the rollback, with MIGRATE_EXITandMIGRATE_ROLLBACK(restored,failed,none). The unit then exits with the migration's status.Every step runs with
MIGRATE_TARGETand, once there is one,MIGRATE_SNAPSHOT. Messages are<label>(<target>): …on stderr, with a<N>syslog prefix when stderr is the journal, so a failed migrate is found atjournalctl -p err. Failed migrations keep their snapshots; only a success prunes.The failure behaviour is wiring, not an argument to the flow: frappe-nix's migrate unit is
RemainAfterExitand the services only order after it, and its migrate andonFailuresteps leave maintenance mode on; odoo-nix'sodoo.serviceRequires=a migrate unit withoutRemainAfterExit, so every start of Odoo migrates first and a failure keeps it down. A product whose own migrate tool decides in-process when a snapshot is due (odoo-nix'sodoo db migrate, which snapshots only when its plan is not empty) vendors python/migrate.py and python/journald.py (lib.nixos.migrate.modules) and callsRunner.take(),restore()andprune(), or gives the flow acheckthat runs its change detection.Adopted: frappe-nix#151 runs the flow (
lib.nixos.migrate.script,verifiers.mysqldump). odoo-nix#73 vendors the modules and subclassesRunnerinsideodoo db migrate.Status: decided.
Check: the base's
lib-nixosevaluates the options and the flow's configuration in a stub product;lib-nixos-migrate-flowruns the flow's unit tests on every preset interpreter;lib-nixos-migrateruns it in a VM against MariaDB and PostgreSQL, in both products' failure shapes. Each product's VM test covers its own steps.
Production secrets
Convention: secrets reach units through
LoadCredential, and init runs as the service user.environmentFilesin both.Adopted: frappe-nix always used
LoadCredentialand gainedservices.frappe-nix.environmentFiles(frappe-nix#151). odoo-nix#73 moved init to the service user withLoadCredential; it already hadenvironmentFiles.Status: decided.
Check: not yet in the base. Each product's module-evaluation tests inspect the units.
Database logging
Convention:
database.slowQueryMsin both.Adopted: frappe-nix#151; odoo-nix#73 (its
logging.slowQueryMsis an alias until 2.0.0).Status: decided.
Check: not yet in the base.
Unit hardening
Convention: every unit a product defines gets
lib.nixos.hardening { readWritePaths, profiles ? [ ], addressFamilies ? [ AF_UNIX AF_INET AF_INET6 ], capabilities ? [ ], overrides ? { } }merged into itsserviceConfig, behindservices.<p>-nix.hardening.enable. The baseline (lib.nixos.hardening.baseline) isNoNewPrivileges,PrivateTmp,PrivateDevices,ProtectSystem=strictwith the unit'sReadWritePaths,ProtectHome,ProtectKernelTunables,ProtectKernelModules,ProtectKernelLogs,ProtectControlGroups,ProtectClock,ProtectHostname,ProtectProc=invisible,RestrictNamespaces,RestrictRealtime,RestrictSUIDSGID,LockPersonality,MemoryDenyWriteExecute,RemoveIPC, an emptyCapabilityBoundingSetandAmbientCapabilities,SystemCallArchitectures=native, andSystemCallFilter=@system-service ~@privilegedfailing withEPERM. Each value is amkDefault, so a host can relax one key on one unit;ReadWritePathsis a plain list, so a host's paths add to the product's.hardening.enableis off through 1.x, because the sandbox can refuse something a running deployment does today, and on from 2.0.0. Off, a unit gets none of these keys.There is no
UMask=0077, here or in any unit: units that write state also write files nginx serves through the service group (Frappe's Website Theme CSS, frappe-nix#83; Odoo's filestore underxSendfile, odoo-nix#51). Private files get their modes where they are written, as the migrate snapshots do.A unit names what it legitimately needs, and documents it next to the call:
Need How Who A JIT (W+X memory) profiles = [ "jit" ]Node (frappe-nix's socketio); odoo-nix's Odoo units Headless Chromium profiles = [ "chromium" ]: the JIT,capset(each child drops its capabilities, even under--no-sandbox), user namespaces,AF_NETLINKfrappe-nix's units that render PDFs, with chromium.enablewkhtmltopdf profiles = [ "wkhtmltopdf" ]: QtWebKit's JITfrappe-nix's units that render PDFs, with wkhtmltopdf.enable; odoo-nix's Odoo unitsOnly unix sockets addressFamilies = [ "AF_UNIX" ]a unit that reaches its database over its socket alone Still runs as root capabilities = [ ... ], which also lets@privilegedcalls through the filternone today: odoo-nix's odoo-initruns as the service user since odoo-nix#73 (Production secrets)Anything else overrides = { <key> = <value>; }, with a comment saying whyProtectSystem=strictleaves/runwritable, so a socket directory needs noReadWritePaths(its mode is the gate: Unix sockets); a state directory does. A path inReadWritePathsmust exist when the unit starts; create it with tmpfiles.Adopted: frappe-nix#151 and odoo-nix#73, opt-in, as above.
Status: deferred to 2.0.0 (as the default; shipped opt-in in both).
Check: the base's
lib-nixosevaluates the baseline, the profiles and the overrides in a stub product, and asserts no unit has aUMask.lib-nixos-hardeningruns Python with a ctypes callback, Node, headless Chromium and wkhtmltopdf under it in a VM, and shows the baseline refuses a JIT and a root unit keeps only the capabilities it names. A product's VM tests run its units with hardening on.
Unix sockets
Convention: a socket lives in a directory of its own (never straight in
/runor/), created by tmpfiles and owned by the service user and group:0750when the service creates the socket and a peer in the group (nginx) only connects,0770when a peer creates its own socket there too (nginx's listen socket). The socket file's own mode gates nothing, since nginx chmods its listen sockets to0666.lib.nixos.unixSocket { user, group, sockets = [ { option, path, peerCreates ? false, example ? … } ] }returns{ assertions, tmpfiles, directories }: one assertion per socket option (<option> must be an absolute path inside its own directory (e.g. …), not directly in /run: the directory's mode is what keeps the socket private.), one tmpfiles rule per directory, and the directories. An unset path (""ornull) is skipped.Adopted: frappe-nix#151 (gunicorn's and socketio's directories
0750, nginx's0770) and odoo-nix#73 (nginx's socket).Status: decided.
Check: the base's
lib-nixos(the rules and the messages) andlib-nixos-migrate(the modes on a running system, with nginx connecting through them).
nginx
Convention: nginx logs to the journal:
the access log as
journal_json, one JSON object per request withtime,site,method,uri,status,bytes,request_time,upstream_time,remote_addr,user_agentandreferer, underSYSLOG_IDENTIFIER=nginx_access:services.nginx.commonHttpConfig = lib.nixos.nginx.accessLog cfg.logging.accessLog;(access_log off;when false).lib.nixos.nginx.journalJsonFormatis thelog_formatline, andjournalJsonFieldsits fields;the error log over syslog, so it keeps its severity:
services.nginx.logError = lib.mkDefault lib.nixos.nginx.errorLog;(aterror;errorLogAt <level>for another).
A virtual host that listens on a unix socket behind a Cloudflare tunnel adds
lib.nixos.nginx.cloudflareRealIp(set_real_ip_from unix:; real_ip_header CF-Connecting-IP;) to itsextraConfig, never on a TCP listener, and each of its locations turnsrecommendedProxySettingsoff and addslib.nixos.nginx.socketProxyHeaders(X-Forwarded-Proto https, since TLS ended at the connector).Adopted: frappe-nix#151 and odoo-nix#73. The product copies are deleted, and odoo-nix's error log moved from stderr to syslog.
Status: decided.
Check: the base's
lib-nixos(the configuration text) andlib-nixos-migrate(the client address from the header in the access log, and an upstream error at priority err).
NixOS module path
Convention:
services.frappe-nixandservices.odoo-nix. These match the devenv and flake-parts roots (frappe-nix.*,odoo-nix.*) and cannot collide with a future nixpkgs module.Adopted: frappe-nix#123, with
services.frappeas an alias until 2.0.0. odoo-nix always usedservices.odoo-nix.Status: decided.
Check: not yet in the base.
Framework config blocks
Convention:
settingswherever a block becomes the framework's config file, following NixOS RFC 42:settingsis a submodule whosefreeformTypeis the file'spkgs.formatstype:pkgs.formats.jsonfor frappe-nix'ssite_config.json, andpkgs.formats.inifor odoo-nix'sodoo.conf, by section (settings.options.<key>), as nixpkgs'services.odoohas it.Keys inside
settingsuse the framework's own spelling (host_name,db_name), declared or not, sosettingsis a one-to-one picture of the file.The module sets the keys it computes in
config.…settings, atmkDefaultwhere a user may change them and at normal priority, with a comment, where the module depends on the value. Readingsettingstherefore shows the effective file (minus secrets), and a user's value wins withoutmkForce.Only a few valuable keys (RFC 42 Part 2: mandatory or commonly set) are declared as typed sub-options; the module's own typed options (ports, workers, database) set their keys rather than being declared twice.
Secrets never go in
settings: frappe-nix hassettingsFilesandencryptionKeyFile, odoo-nixadminPasswordFileandenvironmentFiles, both read throughLoadCredential.The development shell's odoo-nix
settingsis the exception: a typed camelCase tree (settings.dbName) that predates this rule and keeps its shape through 1.x.
Adopted: frappe-nix#123 (
extraConfigandextraConfigFilesaliased), frappe-nix#177 (sites.<fqdn>.settingsfreeform JSON;db_nameanddb_typewarn when they differ fromdatabase.namethrough 1.x, and become module-owned in 2.0.0); odoo-nix#57 and #58 (odooConf.*aliased tosettings, and its port keys toports.*), odoo-nix#91 (settingsfreeform INI by section; the flatsettings.<key>form is an alias forsettings.options.<key>, with a warning, until 2.0.0).Status: decided.
Check: not yet in the base. A check that each product's
settingsoption has apkgs.formatsfreeform type would be cheap (#19).
Identity options
Convention: the project identity is
namein both (frappe-nix.name,odoo-nix.name). It is a slug: it seeds the port hash and names container images, systemd scopes and log messages. Each devenv module sets devenv's own top-levelnamewithmkDefault cfg.name. The default tenant keeps the framework's word: frappe-nix'sdefaultSite(Frappe'sdefault_site), and odoo-nix'sdbName(Odoo'sdb_name). The scaffolder's--namesets the identity in both, and frappe-nix's--default-sitethe default site. Production keeps nixpkgs' shapes: frappe-nix'ssites.<fqdn>(asservices.wordpress.sites), odoo-nix'sdomain(asservices.odoo.domain). In frappe-nix, the word site means only what Frappe means by it: a tenant of the bench.Adopted: frappe-nix#123 (
benchNameandsiteNamealiased) and odoo-nix#57 (projectNamealiased), until 2.0.0.Status: decided.
Check: not yet in the base. Each product's rename tests assert the aliases (frappe-nix's
tests/renames.nix).
Logging
Journald enablement
Convention: journald formatting is always on when stderr is the journal, loaded through a
.pthgraft built withlib.journald.mkGraft { pkgs, name, src }(<p>_journald), which puts the post-import hook (_hook.py) and the formatter core (_journald.py) beside the product's tree. The product'sinstall()does nothing unless_journald.journal_stream():JOURNAL_STREAMnames stderr's own device and inode, which an inherited variable on a redirected stderr does not. It ships to production as well as development, inert wherever stderr is not the journal. A command line that logs to the journal itself vendorslib.journald.module.Adopted: frappe-nix#151 and odoo-nix#73, which retired its sitecustomize shim;
services.odoo-nix.logging.journaldis deprecated and does nothing.Status: decided.
Check: the base's
lib-journaldgrafts a stub product and pointsJOURNAL_STREAMat a file, on every preset interpreter.
Journald vocabulary
Convention: every unit carries
LogExtraFieldsAPP_SERVICE=<role>, plusAPP_SITE=<site or database>when it serves exactly one, and a unit the product defines hasSyslogIdentifier=<p>-<role>; an upstream unit the product only enables (the database, Redis, nginx) keeps its own identifier.serviceConfig = { ... } // lib.journald.fields { prefix = "<p>"; role; site ? null; identifier ? … };writes them, and refuses a role not in the list. The roles (lib.journald.roles):Role Unit runtimefrappe-nix's unified runtime (web, realtime, jobs, scheduler) webthe HTTP server: gunicorn, odoo.serviceworkera background-job worker schedulerthe scheduler socketiothe realtime server initsetup before anything else starts migratemigrate on deploy dbthe database server redisRedis nginxnginx A unit that needs telling apart within its role passes
identifier(frappe-nix'sfrappe-worker-<queue>,frappe-db-password).Adopted: frappe-nix#151; odoo-nix#73 (
init,migrate,webanddb).Status: decided.
Check: the base's
lib-nixosevaluates the fields in a stub product, and frappe-nix'stests/logging-fields.nixand odoo-nix'stests/module-eval.nixassert them againstlib.journald.roles.
Journald line format
Convention:
<N>{logger} [{site or db}] msg, fromJournaldFormatter(logger=None, tenant=None)in the core (python/journald.py).<N>is the record's syslog priority by threshold (critical 2, error 3, warning 4, info 6, below 7), on every line of the record, tracebacks included; ANSI escapes are stripped; there is no timestamp, pid or level name, which journald records itself. The product says who the tenant is (tenant(record): Frappe'sfrappe.local.site, Odoo'srecord.dbname), may fix the logger name (Frappe prints the module, not<module>-<site>), and may append what its framework puts after a message by overridingmessage()(Odoo'sperf_info). Its patching into the framework's logging stays its own.Adopted: frappe-nix#151 and odoo-nix#73 (
OdooFormatter, on the core'sJournaldFormatter).Status: decided.
Check: the base's
lib-journald, on every preset interpreter.
Containers
Images
Convention:
images are
[<registry>/]<name>/<role>, and flake outputs arecontainer-<role>;every image carries OCI labels:
org.opencontainers.image.title,source,version,licenses, andrevisionwhere the product knows it;every image that serves HTTP has a healthcheck;
with
containers.nonRoot, an image runs as the product's user, uid and gid 1000, with its own/etc/passwdand/etc/group.containers.nonRootis off through 1.x, because a volume a root image wrote needs a one-timechownfirst, and on from 2.0.0;an nginx image keeps port 80. Run as non-root under rootless Podman, it needs
--sysctl net.ipv4.ip_unprivileged_port_start=0.
Adopted:
frappe-nix#157:
[<registry>/]<name>/<role>andcontainer-<role>, with the old output names aliased until 2.0.0 (frappe-nix.containers.aliases). The runtime, web, socketio and nginx images have a healthcheck; scheduler, the workers and bench do not. Withcontainers.nonRootthe images run asfrappe.odoo-nix#81: the output is
container-odoo. The image name stays[<registry>/]<name>-odooby default through 1.x, with a warning, and becomes<name>/odoowithcontainers.roleImageNames, the default from 2.0.0. Withcontainers.nonRootit runs asodoo, home/var/lib/odoo.containers.labelsoverrides the labels.
Status: decided for the outputs, the labels and the healthchecks; deferred to 2.0.0 for
containers.nonRootand odoo-nix's role image name.Check: not yet in the base. Each product evaluates its image configs (odoo-nix's
container-user). A sharedlib.containerswould put these pieces in one place (#20).
Repository
Formatter
Convention: nixfmt-rs, through
nix fmtand the nix-lint check (nixfmt, statix, deadnix), pinned by this repository'slib.checks.nixLint. The check also comparesstatix.tomlwith this repository's copy (Repository files). Each product registers it over its whole tree, leaving out what is not its own Nix:nix-lint = python-devops-nix.lib.checks.nixLint { inherit pkgs; src = ./.; product = "odoo-nix"; ignore = [ "tests/fixtures" "templates" ]; };Adopted: both. odoo-nix's switch from nixfmt was one formatting commit, listed in
.git-blame-ignore-revswith odoo-nix#76's prek sweep.Status: decided.
Check: the nix-lint check (
lib-nix-lintin the base).
CI
Convention: every action is pinned by full commit SHA, with the version in a trailing comment. A
lintjob runs prek over the whole tree. Aflakejob runsnix flake checkand builds the checks; VM tests may run in a job of their own. The docs are checked byAvunu/site-docs'scheck-docs.yml(Docs pipeline). Dependabot pull requests auto-merge through this repository's reusable workflow, called pinned the same way. This repository has no release yet, so the pin is a commit ofmain, and Dependabot's github-actions entry moves it:on: pull_request permissions: {} jobs: auto-merge: if: github.event.pull_request.user.login == 'dependabot[bot]' permissions: contents: write # gh pr merge --auto pull-requests: write # gh pr merge --auto uses: Avunu/python-devops-nix/.github/workflows/dependabot-auto-merge.yml@<sha> # main (no release yet)Its inputs carry the policy.
update-types(defaultmajor minor patch other, whereotheris an update with no semver type, such as a flake input) lists what auto-merges;exclude-ecosystems(defaultgithub_actions) lists ecosystems a person reviews;merge-method(defaultsquash) is passed togh pr merge. frappe-nix and odoo-nix take the defaults. The workflow decides by ecosystem and update type, not by group, so a group a person reviews is the caller'sif: odoo-nix skipsdependabot/nix/ocb-*, because an OCB bump can need a relock. wordpress-nix takes the defaults too: it merges major bumps, and its gate is its required OCI build, not an input. A repository that wants majors reviewed passesupdate-types: minor patch other.Auto-merge is only as safe as the checks marked required on the default branch, so
flakeis a required check. Without it, auto-merge merges a bump before its checks fail.Adopted:
frappe-nix: SHA pins and the
lint,flakeandvm-testsjobs; the reusable workflow since frappe-nix#159. Itsmainrequires the docs check (build / Check) andflake;flakebecame required on 2026-10-22, after frappe-nix#163 merged with it failing.odoo-nix: SHA pins, the
lintjob (odoo-nix#76), andflake, which builds every check; the reusable workflow since odoo-nix#78. Itsmainrequiresflakeandpr-policy, since odoo-nix#63 merged red.
Status: partial: frappe-nix's required checks.
Check: not yet in the base for the
uses:pins (#19). Required checks are repository settings, which no check in the tree can see.
Repository files
Convention: the same set in both:
.editorconfig,.envrc,.pre-commit-config.yaml,ruff.toml,ty.tomlandstatix.toml, andversion.txtonce releases are wired up. Each product keeps its own copy at its root, because the tools read them from there, and the shared settings in each match this repository's copy.The rule is the same for every shared file: the product's copy makes every setting this repository's copy makes, with the same value, and may add its own. Not byte-identical copies, because the products legitimately add to them: frappe-nix's
ruff.tomlextends Frappe's own ruff config, and both products' prek configurations add hooks and exclude their own paths. A list is one value and is compared whole. Per file, a setting is:File A setting is Not compared statix.toml,ruff.tomleach key, by its path through the TOML tables — .editorconfigeach key under a section ( [*.py] indent_style), and the preamble'sroot— .pre-commit-config.yamleach hook, by repository URL and hook id(with itsnamewhere the base gives one), and each key the base sets on it (args)rev, which each repository's Dependabot moves on its own schedule; the top-levelexcludeand other keys, which name the repository's own pathsWhere a product has to set a shared setting differently, it lists the setting as an exception in its check and says why in a comment next to it: frappe-nix's Python is written in Frappe's style, so its
[*.py] indent_styleistab. An exception for a setting this repository no longer makes fails the check, so exceptions do not outlive their reason.ruff's
extendwas the obvious alternative forruff.toml, and it was rejected: it takes a path, and this repository's copy is in the Nix store, which an editor or a prek run outside the development shell does not have. The base's ruff settings are few (thetarget-versionfloor), so copying them in is cheaper than any mechanism that resolves a store path.Adopted: frappe-nix has every file, with the
[*.py] indent_styleexception. odoo-nix has every file since odoo-nix#76 (.envrc,ty.toml), with no exceptions. Both haveversion.txt.Status: decided.
Check:
lib.checks.nixLintcomparesstatix.toml, andlib.checks.sharedConfigthe rest:shared-config = python-devops-nix.lib.checks.sharedConfig { inherit pkgs; src = ./.; product = "frappe-nix"; # Python here is Frappe's style: tabs, as Frappe's own .editorconfig. exceptions.".editorconfig" = [ [ "*.py" "indent_style" ] ]; };filesdefaults to.editorconfig,.pre-commit-config.yamlandruff.toml; a product that has no Python yet can dropruff.tomlfrom it until it does.
Dependabot
Convention: one shape: commit prefix
fix(deps), so a merged bump cuts a patch release through release-please; a seven-day cooldown (cooldown.default-days: 7); and one group per ecosystem (patterns: ["*"]), plus named groups only where inputs must move apart (odoo-nix'socbandodoo-ls). A bump to development-only tooling that ships nothing keepschore(deps). Each product lists its own ecosystems. Pull requests auto-merge, except github-actions bumps, which a person reviews, through the reusable workflow in CI.Adopted:
frappe-nix#159: nix, pre-commit and github-actions with
fix(deps), and uv in/dev(its own lint and type-check tools, thedev-toolsgroup) withchore(deps). Every ecosystem has the cooldown.odoo-nix#78: nix (with the
ocbandodoo-lsgroups), pre-commit and github-actions, allfix(deps), all with the cooldown. It has no uv entry, because its template's lock cannot be resolved without a project.This repository, which has no releases, uses
chore.
Status: decided.
Check: not yet in the base: a check over
.github/dependabot.ymlfor the prefix and the cooldown (#19).
Docs pipeline
Convention:
Avunu/site-docs'scheck-docs.yml, and frappe-nix's page structure: getting started, scaffolding, development, production, reference.Adopted: frappe-nix always. odoo-nix#79 dropped
docs-site/anddocs-publish.ymland split its README into those pages.Status: decided.
Check: not yet in the base; the workflow each product calls is visible in its
.github/workflows.
Tests
Convention: every check and VM test a product defines is a
checks.<system>.<x>attribute, registered through one helper,lib.checks.mk. The naming scheme:<x>is kebab-case and names what is checked (agecheck,db-nocow,migrate-rollback), never the kind of check: noeval-,module-orvm-prefix, because the helper already records the kind.A check that runs once per framework series takes the series' major version as a suffix:
<x>-<major>(devguard-18,devguard-19).The derivation is named
<product>-<x>-check(frappe-nix-agecheck-check), so a build log names the product and the check. VM tests keep nixpkgs' prefix:vm-test-run-<product>-<x>.The test's own files sit beside the code (
tests/<x>.sh,tests/<x>.nix,tests/test_<x>.py), named by the same<x>.
lib.checks.mk { pkgs, product }returns:Function Use script <x> { src ? null, tools ? [ ], env ? { }, drvArgs ? { } } <text>A shell script, run with toolson PATH, in a writable copy ofsrcif given, with a writableHOMEand pipefail on. It passes when it exits 0. It may write a report to$out, a file or a directory.eval <x> { assertion, message }oreval <x> { expected, actual, message ? "" }A Nix expression, judged at evaluation and reported at build, so a failure fails one named check, not the whole evaluation. The failure prints the message and, for expected/actual, a diff of their JSON.nixosTest <x> <module>A NixOS VM test ( pkgs.testers.runNixOSTest), named by the helper; the module sets noname.checkName <x>The derivation name, <product>-<x>-check, for a check built some other way.lib.checks.nixLintandlib.checks.sharedConfigare built with it and take the sameproduct.Adopted: odoo-nix#77 and frappe-nix#160 register every check through it (odoo-nix's
module-nginxbecamenginx). Every frappe-nix derivation isfrappe-nix-<x>-checkorvm-test-run-frappe-nix-<x>, exceptruntime, which is thefrappe-runtimepackage itself.Status: decided.
Check: the base's
lib-mktests the helper. A pattern over check attribute names is not yet in the base (#19).
Commits
Convention: conventional commits, enforced by
committedin a requiredpr-policyjob, with one scope list for both products. A breaking change is a deliberateRelease-As: N.0.0footer, never!orBREAKING CHANGE, which the policy refuses;deprecateis a commit type of its own.Adopted: both enforce it with
committed.tomlandpr-policy. The scope lists differ: frappe-nix's has 61 scopes, the union of both repositories' history (frappe-nix#159), and odoo-nix's has 30.Status: partial: one scope list, owned by this repository (#21).
Check: each product's
pr-policyjob. Not yet in the base, which has nocommitted.tomlof its own.
The development shell option tree
Status: decided (adopted as proposed on 2026-10-17). Adopted: frappe-nix#124 and odoo-nix#58.
Both devenv modules had a devguard tree. odoo-nix also had a dev tree, which held one option, dev.autoReload. Since the whole module configures the development shell, dev named nothing, and the options related to it were spread elsewhere in both products (odoo-nix: odooConf.devMode, odooConf.workers, testBrowser, ide; frappe-nix: runtime.dev, watch.*, assets.reassert.*). devguard names a real subsystem and stays, but held the Mailpit catcher's settings, which are not a guard. Ports were spread over four trees in odoo-nix.
For both products:
| Tree | Holds | Moved in |
|---|---|---|
watch | What the shell does when a file changes. watch.python (bool, default true): restart the server on a Python change. frappe-nix keeps watch.{apps,excludePublishers,rtl,nativeSass} and has watch.reassert. | odoo-nix dev.autoReload and frappe-nix runtime.dev became watch.python; frappe-nix assets.reassert became watch.reassert. odoo-nix keeps its assertions that watch.python needs workers = 0 and a reloading devMode. The dev tree is removed. |
devguard | Guard policy only: what is refused or redirected. Shared guards egress, mail, jobs, backups, objectstore and webhooks, each with enable and the same shape. Product guards beside them (frappe-nix integrations, google, plaid; odoo-nix iap). | scheduler and crons became jobs. devguard.mail keeps enable, sender and unmute. |
mailpit | The catcher as a service: mailpit.{enable,host,port,httpPort} and mailpit.pop3.{enable,port,user,password}, with ports defaulting from ports. On by default in both products, whether or not the mail guard is on. | devguard.mail.{host,smtpPort/port,httpPort} from both, and frappe-nix's devguard.mail.pop3.*. |
ports | ports.{base,offset,worktreeSalt} and one derived attribute per service port (ports.http, ports.smtp, ports.mailUi, ports.db, …) that the rest of the module reads, all from lib.ports.options. Each can still be forced with lib.mkForce, which is why they are not readOnly: a read-only option refuses that override too. A product that leases its ports at run time (see Ports) adds ports.strict (refuse a taken preferred port rather than move past it; null for the product's default) and ports.span (how many ports past the preferred one to try) beside them; frappe-nix declares both itself, outside lib.ports.options, and odoo-nix takes the same names if it leases. | odoo-nix odooConf.httpPort, odooConf.geventPort and odooConf.dbPort, and the Mailpit ports. |
This keeps the decided name port: the SMTP port is mailpit.port, which defaults to ports.smtp, and the mail guard redirects to it. Mailpit starts before the server from the mailpit tree, so a server with the mail guard on never starts before its catcher. Every old option path is an alias until 2.0.0.
Left alone, since neither product is clearly better: frappe-nix's appsReconcile, renamedApps and replacedApps, and odoo-nix's layout.*.
Both products also have ide.enable, which links ./.venv to the development environment for editors: frappe-nix's (frappe-nix#156, default true) also generates a pyrightconfig.json; odoo-nix's, the original, seeds .vscode/settings.json and runs odoo-ls under ide.languageServer.
Check: not yet in the base: the option paths exist in both devenv modules, and the dev tree does not (#19).