Run Odoo as a NixOS service
Deploy Odoo on NixOS with the odoo-nix services.odoo-nix module, covering PostgreSQL, nginx, secrets, migrations on deploy, backups, restores and logs.
On this page
This document explains how to run a production Odoo (OCB plus OCA modules) on a NixOS host using the services.odoo-nix module from odoo-nix. Use it when you already have an odoo-nix project flake and want to deploy it, keep its database in step with the code, back it up and read its logs.
The module runs one Odoo instance per host. You can still serve several databases from that instance with a database filter.
What the module sets up
Enabling the module creates three systemd units, an odoo system user, and a state directory.
| Unit | Type | What it does |
|---|---|---|
odoo-init.service | oneshot, stays active | Writes the runtime odoo.conf and merges in secrets. |
odoo-migrate.service | oneshot | Brings the database schema in line with the code before every start of Odoo. |
odoo.service | long running | Runs odoo -c /var/lib/odoo/odoo.conf as the odoo user and restarts it after five seconds if it exits. |
Odoo keeps its state in /var/lib/odoo (the stateDir option). The runtime config is /var/lib/odoo/odoo.conf and the data directory, which holds the filestore and the default backup location, is /var/lib/odoo/data.
How secrets stay out of the Nix store
A base odoo.conf without secrets is generated into the Nix store. At boot, odoo-init copies it to /var/lib/odoo/odoo.conf with mode 0600, then appends db_password and admin_passwd from the files you point at. The secret values never enter /nix/store, and the file is readable only by the odoo user.
Deploy the module
You need a project flake built with odoo-nix. Its packages.<system>.default output is the assembled Odoo wrapped with the odoo command line tool, and that is what the module expects as package.
Warning
Point package at packages.default, not at the raw builtOdoo tree. The raw tree has no odoo db migrate, and the module refuses to evaluate with migrations enabled unless you pass the CLI package.
Import the module and configure it in your host's NixOS configuration:
{
imports = [ odoo-nix.nixosModules.default ];
services.odoo-nix = {
enable = true;
package = projectFlake.packages.x86_64-linux.default;
dbName = "acme";
database.createLocally = true;
adminPasswordFile = "/run/secrets/odoo-admin";
workers = 4;
nginx = {
enable = true;
domain = "erp.example.com";
};
};
}Then rebuild and check that the units came up:
sudo nixos-rebuild switch --flake .#<HOST_NAME>
systemctl status odoo-init odoo-migrate odooodoo-migrate shows as inactive after a successful run, because it is a oneshot unit that does not stay active. That is expected.
Options you will use most
| Option | Default | Purpose |
|---|---|---|
package | none, required | The project flake's packages.default. |
dbName | null | Pins the instance to one database (sets db_name and dbfilter to ^<name>$). |
dbFilter | "" | Explicit dbfilter regex for several databases. Overrides the one dbName sets. |
listDb | false | Allows the database manager and database listing. |
workers | 2 | HTTP worker processes. 0 runs threaded, anything above enables multiprocess mode plus the gevent websocket process. |
maxCronThreads | 2 | Cron threads. |
http.port | 8069 | HTTP port. |
http.longpollingPort | 8072 | Websocket (gevent) port. |
http.interface | 127.0.0.1 | Bind address. Keep it on loopback behind nginx. |
adminPasswordFile | null | File holding the database manager master password. |
withoutDemo | false | Skips demo data for every module. |
settings | { } | Extra [options] keys for odoo.conf. No secrets here, because it lands in the Nix store. |
extraEnv | { } | Extra environment variables for the Odoo units. |
stateDir | /var/lib/odoo | Where the runtime config and data directory live. |
Set adminPasswordFile. Without it, odoo-init writes no admin_passwd line at all.
PostgreSQL
You have two choices: let the module run PostgreSQL on the same host, or point it at a server you manage.
A local database
With database.createLocally = true, the module enables services.postgresql, creates the database named by dbName, and creates a PostgreSQL role (database.user, default odoo) with createdb. When database.passwordFile is unset, Odoo connects over the local unix socket using peer authentication, so there is no password to manage and no db_host or db_port in the config.
The role is not a superuser. That matters for ownership and for extensions:
If
dbNamematches the role name, the module uses NixOS'sensureDBOwnership. If the names differ, it runsALTER DATABASE ... OWNER TOafter the database is created, so the Odoo role still owns it.A module that tries to create an untrusted extension in its install hook will fail as this role. Declare the extension in the module instead:
database.extensionsbuilds it into the server (for exampleps: [ ps.postgis ]), anddatabase.ensureExtensionsrunsCREATE EXTENSION IF NOT EXISTSfor each name indbNameas thepostgressuperuser. Both needcreateLocally, andensureExtensionsalso needs a pinneddbName.
The units order themselves after postgresql.target, so the migration does not race the role creation.
A remote database
Leave createLocally off and set database.host, database.port (default 5432), database.user and database.passwordFile. The password file is merged into odoo.conf by odoo-init. The module creates nothing on a remote server, so the role and database must exist before the first start.
Several databases
Leave dbName unset and set dbFilter to a regex that matches your databases. Some options need a pinned dbName and fail evaluation without one: update, autoInit, and database.ensureExtensions. With no pinned database, migrations run against every database that matches the filter (--all).
nginx
Set nginx.enable = true to get a reverse proxy in front of Odoo. The module also turns on Odoo's proxy_mode when nginx is enabled. The vhost proxies these paths:
/to the HTTP port./websocketand/longpollingto the gevent port, with websocket upgrade handling.Paths matching
/web/static/to the HTTP port, with caching and a ten-dayexpires.
You must provide nginx.domain (the vhost name) unless you use socket mode. The request body limit is nginx.clientMaxBodySize, default 64m, scoped to this vhost only so other sites on the host keep their own limit.
Note
The module does not enable TLS. In the default TCP mode it forwards X-Forwarded-Proto from the scheme nginx sees, so terminate TLS on this nginx with the usual NixOS vhost options (services.nginx.virtualHosts."erp.example.com"), or Odoo will build http links.
Socket mode behind a tunnel connector
If a co-located reverse proxy or tunnel connector terminates TLS for you, set nginx.socketPath to a unix socket path such as /run/odoo/nginx.sock. Then nginx listens on the socket instead of a TCP port, and you no longer need nginx.domain.
Socket mode changes three things:
The public scheme is assumed to be
https, soX-Forwarded-Protois fixed tohttps.Because a unix socket has no peer address, the client IP is read from the
CF-Connecting-IPheader. Only use this mode when the connector in front of the socket sets that header.nginx sets its own socket to world-writable, so the socket file does not restrict access. The module creates the containing directory with mode
0770, owned by the Odoo user and group, and adds thenginxuser to that group. The directory is the real access control, so the path must sit inside its own directory (not directly in/run).
Odoo itself always stays on loopback TCP. Its server layer cannot bind a unix socket.
Migrations on deploy
The schema follows the code automatically. Odoo only creates columns, loads views and runs migration scripts for modules that are installed or updated, so shipping new module code without an update leaves the database quietly behind the code. odoo-migrate.service closes that gap.
It runs before every start of odoo.service. Because it has no RemainAfterExit, it goes inactive when done, and odoo.service both requires it and orders itself after it. In practice:
A deploy stops Odoo, migrates exactly the modules whose code or manifest version changed, then starts Odoo. Nothing touches the database mid-update.
A restart of the same build (reboot,
systemctl restart odoo, a crash) costs one database query and changes nothing.A restored dump is migrated on the next start, because the "last migrated by" marker lives in the database and a dump from elsewhere carries a different marker or none.
A failed migration is rolled back from its snapshot, and
odoo.servicedoes not start. Odoo stays down on purpose, because new code on an old schema fails in ways nothing reports.
The unit runs odoo db migrate with --if-needed, a dump snapshot, --keep set to your retention and --rollback. The first run on a database with no recorded checksums updates every module once to set the baseline, which can take many minutes on a large database.
Tuning the migration
| Option | Default | Effect |
|---|---|---|
migrate.enable | true | Run the migration before each start. |
migrate.full | false | Update every installed module on each new build, not only changed ones. |
migrate.snapshot | true | Take a pg_dump snapshot first. |
migrate.rollbackOnFailure | true | Restore the snapshot if the update fails. Needs migrate.snapshot. |
migrate.snapshotRetention | 3 | Snapshots kept per database. |
migrate.timeout | infinity | TimeoutStartSec for the unit. Killing a migration part way leaves a half-updated schema for the rollback to undo. |
update | [ ] | Modules to update on every deploy even when unchanged. Needs a pinned dbName. |
autoInit | false | Install base into an empty pinned database on first boot. |
Snapshots live in /var/lib/odoo/data/backups/<DB_NAME>/premigrate/, separate from regular backups so retention never prunes a backup you took on purpose. They hold the database only. The filestore is left alone, because attachments are content-addressed and only ever added.
With autoInit off, an empty database is skipped and Odoo still starts, which is what you want when you plan to restore a dump into it. With autoInit on, the migration unit passes --provision-if-empty and installs base. The provisioning step also reads a modules.txt from its working directory, which is /var/lib/odoo, and installs the modules listed there. The module does not create that file for you.
Setting migrate.enable = false falls back to the older behavior: autoInit installs base once and update runs odoo db upgrade on every start, with no change detection and no snapshot.
Recovering from a failed migration
Read the log first:
journalctl -u odoo-migrate.service --no-pagerThen fix the code and deploy again, or roll the system back to the previous generation. The database has already been restored from the snapshot, so the previous generation's code matches it.
Backups and restores
The module does not schedule backups. It ships the odoo command with backup and restore subcommands, and you run them yourself or from a timer you define.
Running the odoo command on the host
The module does not put odoo on your PATH. Run it as the odoo user (the runtime config is 0600) and give it the config file. Find the package path in the unit's ExecStart line:
systemctl cat odoo.serviceThen run commands like this, replacing <ODOO_PACKAGE> with the store path from ExecStart:
sudo -u odoo <ODOO_PACKAGE>/bin/odoo -c /var/lib/odoo/odoo.conf db list-c and -d are accepted before the subcommand. The backup and restore tools need the PostgreSQL client programs (pg_dump and friends) on the PATH of the shell you run from. The systemd units get them automatically, an interactive shell may not.
Back up
db backup writes to <data_dir>/backups/<DB_NAME>/ by default, which is /var/lib/odoo/data/backups/<DB_NAME>/ here:
sudo -u odoo <ODOO_PACKAGE>/bin/odoo -c /var/lib/odoo/odoo.conf db backup <DB_NAME>Useful flags:
--allbacks up every database that matchesdbfilter.--path DIRchanges the destination directory.--format zip|dumppicks the format.zip(the default) is Odoo's own Database Manager format with the filestore included.dumpis a plainpg_dumpcustom-format file with no filestore.--keep-days Ndeletes older backups for that database. The default0keeps everything.
Files are named by timestamp (YYYY_MM_DD_HH_MM_SS.dump.zip, or .dump), with no database name in the file name because the folder carries it. That is the same convention as the OCA auto_backup module, so backups from either tool can share a folder.
To back up every database at once without naming them, use project backup. It takes the same --format, --path and --keep-days flags and writes each database into its own subfolder:
sudo -u odoo <ODOO_PACKAGE>/bin/odoo -c /var/lib/odoo/odoo.conf project backup --keep-days 14Warning
The pre-migration snapshot is a database-only dump. A dump backup has no filestore either. If you need to recover attachments, keep zip backups, and copy them off the host.
Restore
Stop Odoo first so nothing holds the database open:
sudo systemctl stop odoo.serviceThen restore a backup into a database name. --force drops the database first if it already exists, and --neutralize disables outgoing mail and cron on the restored copy, which you want when restoring production data somewhere else:
sudo -u odoo <ODOO_PACKAGE>/bin/odoo -c /var/lib/odoo/odoo.conf db restore <DB_NAME> <BACKUP_PATH> --forceStart Odoo again. The migration unit runs first and brings the restored database up to the code in this build:
sudo systemctl start odoo.serviceTo restore several databases in one command, repeat --restore DB PATH with project restore. You name each file explicitly, because there is no latest-backup lookup.
Note
This document does not cover restoring a premigrate snapshot by hand. The automatic rollback uses it for you. For manual recovery, restore from a zip backup.
Logging
Everything goes to the journal. There are no log files unless you ask for one.
Fields to filter on
Every unit the module defines or enables carries two journal fields: APP_SERVICE, the role, and APP_SITE, the pinned dbName. With no pinned dbName, APP_SITE is omitted.
APP_SERVICE | Unit |
|---|---|
web | odoo.service |
migrate | odoo-migrate.service |
init | odoo-init.service |
db | postgresql and postgresql-setup, with createLocally |
nginx | nginx, with nginx.enable |
The Odoo units also have fixed SyslogIdentifier values: odoo, odoo-migrate and odoo-init. Some queries you will use often:
journalctl APP_SITE=<DB_NAME> -p warning
journalctl APP_SERVICE=migrate APP_SITE=<DB_NAME>
journalctl -u odoo -fPriorities and request logs
With logging.journald (on by default), Odoo's records reach the journal with their real syslog priority, on every line of a record including tracebacks, so -p warning filters correctly. This only applies when logging.file is null and settings sets neither logfile nor syslog.
With logging.accessLog (on by default and only with nginx.enable), nginx writes one JSON object per request to the journal under SYSLOG_IDENTIFIER=nginx_access. Fields are time, site, method, uri, status, bytes, request_time, upstream_time, remote_addr, user_agent and referer:
journalctl SYSLOG_IDENTIFIER=nginx_access -o cat | jq 'select(.status >= 500)'Other logging options
logging.levelsets the root level (defaultinfo) andlogging.handlerstakes per-logger overrides such aswerkzeug:WARNING.logging.slowQueryMslogs PostgreSQL statements that run at least that many milliseconds. It only applies withcreateLocally. Local PostgreSQL log lines are prefixed with database and role.logging.filewrites to a file instead. Odoo's file and stderr handlers are mutually exclusive, so the journal then sees almost nothing from Odoo, and the module prints a warning.logging.rotatewires uplogrotatefor that file. Prefer the journal under systemd.
Upgrading
An upgrade is a normal NixOS deploy. Update the code in your project repository (OCB, OCA submodules, your own modules), rebuild the project flake, and switch:
nix flake update <PROJECT_INPUT>
sudo nixos-rebuild switch --flake .#<HOST_NAME>When the switch replaces the Odoo unit, systemd stops Odoo, runs odoo-migrate, and starts the new build. Check the result with journalctl APP_SERVICE=migrate APP_SITE=<DB_NAME>. The migration prints its plan before it runs, so you can see which modules it updates and why.
The odoo module add and odoo project update commands are for the development shell only. A deployment built into /nix/store has no git checkout or modules.txt, and they fail with a clear message there.
To roll back a bad release, switch to the previous generation. If the migration had already failed, the database was restored from its snapshot, so the old code still matches it.
Running Odoo in a container
The same project can build an OCI image. Set odoo-nix.containers.enable = true in the project flake to get packages.<system>.container-odoo, a single image that runs HTTP workers, gevent and cron. PostgreSQL stays external.
The image builds /etc/odoo/odoo.conf at start from environment variables. These are the main variables the entrypoint reads. It also reads ODOO_LOG_HANDLER and ODOO_LOG_DB, which are left out of the config when unset, and ODOO_LOG_DB_LEVEL, which defaults to warning.
| Variable | Default |
|---|---|
ODOO_DB_HOST | db |
ODOO_DB_PORT | 5432 |
ODOO_DB_USER | odoo |
ODOO_DB_NAME | unset (also sets dbfilter) |
ODOO_HTTP_PORT / ODOO_GEVENT_PORT | 8069 / 8072 |
ODOO_WORKERS / ODOO_MAX_CRON_THREADS | 2 / 2 |
ODOO_PROXY_MODE | True |
ODOO_LIST_DB / ODOO_WITHOUT_DEMO | False / False |
ODOO_LOG_LEVEL | info |
Mount the persistent filestore at /var/lib/odoo/data. Secrets come from mounted files: /secrets/db_password, /secrets/admin_passwd, and any /secrets/*.conf, which is appended to the config.
Warning
The container does not run migrations on start. Its entrypoint launches Odoo directly. After deploying a new image, run odoo db migrate <DB_NAME> yourself inside the container.
The odoo command in the image works the same way as on the host, and ODOO_RC is already set, so docker exec <CONTAINER> odoo db backup <DB_NAME> needs no -c flag.
Related documents
Odoo Development Environment with odoo-nix, for creating the project flake this module deploys.
Adding OCA Modules with odoo-nix, for choosing the modules that go into it.
Reference Architecture for Self-Hosting Business Systems, for running Odoo behind a tunnel on a small fleet of NixOS hosts.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.