Skip to content
Skip to the article
In NixOS: 13 articles
NixOS

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.

Updated
Applies to
  • odoo-nix services.odoo-nix
  • Odoo 18.0
  • Odoo 19.0
Tags
  • nixos
  • odoo
  • deployment
  • systemd
Reading time
14 min

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.

UnitTypeWhat it does
odoo-init.serviceoneshot, stays activeWrites the runtime odoo.conf and merges in secrets.
odoo-migrate.serviceoneshotBrings the database schema in line with the code before every start of Odoo.
odoo.servicelong runningRuns 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 odoo

odoo-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

OptionDefaultPurpose
packagenone, requiredThe project flake's packages.default.
dbNamenullPins the instance to one database (sets db_name and dbfilter to ^<name>$).
dbFilter""Explicit dbfilter regex for several databases. Overrides the one dbName sets.
listDbfalseAllows the database manager and database listing.
workers2HTTP worker processes. 0 runs threaded, anything above enables multiprocess mode plus the gevent websocket process.
maxCronThreads2Cron threads.
http.port8069HTTP port.
http.longpollingPort8072Websocket (gevent) port.
http.interface127.0.0.1Bind address. Keep it on loopback behind nginx.
adminPasswordFilenullFile holding the database manager master password.
withoutDemofalseSkips 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/odooWhere 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 dbName matches the role name, the module uses NixOS's ensureDBOwnership. If the names differ, it runs ALTER DATABASE ... OWNER TO after 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.extensions builds it into the server (for example ps: [ ps.postgis ]), and database.ensureExtensions runs CREATE EXTENSION IF NOT EXISTS for each name in dbName as the postgres superuser. Both need createLocally, and ensureExtensions also needs a pinned dbName.

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.

  • /websocket and /longpolling to the gevent port, with websocket upgrade handling.

  • Paths matching /web/static/ to the HTTP port, with caching and a ten-day expires.

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, so X-Forwarded-Proto is fixed to https.

  • Because a unix socket has no peer address, the client IP is read from the CF-Connecting-IP header. 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 the nginx user 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.service does 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

OptionDefaultEffect
migrate.enabletrueRun the migration before each start.
migrate.fullfalseUpdate every installed module on each new build, not only changed ones.
migrate.snapshottrueTake a pg_dump snapshot first.
migrate.rollbackOnFailuretrueRestore the snapshot if the update fails. Needs migrate.snapshot.
migrate.snapshotRetention3Snapshots kept per database.
migrate.timeoutinfinityTimeoutStartSec 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.
autoInitfalseInstall 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-pager

Then 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.service

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

  • --all backs up every database that matches dbfilter.

  • --path DIR changes the destination directory.

  • --format zip|dump picks the format. zip (the default) is Odoo's own Database Manager format with the filestore included. dump is a plain pg_dump custom-format file with no filestore.

  • --keep-days N deletes older backups for that database. The default 0 keeps 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 14

Warning

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.service

Then 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> --force

Start Odoo again. The migration unit runs first and brings the restored database up to the code in this build:

sudo systemctl start odoo.service

To 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_SERVICEUnit
webodoo.service
migrateodoo-migrate.service
initodoo-init.service
dbpostgresql and postgresql-setup, with createLocally
nginxnginx, 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 -f

Priorities 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.level sets the root level (default info) and logging.handlers takes per-logger overrides such as werkzeug:WARNING.

  • logging.slowQueryMs logs PostgreSQL statements that run at least that many milliseconds. It only applies with createLocally. Local PostgreSQL log lines are prefixed with database and role.

  • logging.file writes 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.rotate wires up logrotate for 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.

VariableDefault
ODOO_DB_HOSTdb
ODOO_DB_PORT5432
ODOO_DB_USERodoo
ODOO_DB_NAMEunset (also sets dbfilter)
ODOO_HTTP_PORT / ODOO_GEVENT_PORT8069 / 8072
ODOO_WORKERS / ODOO_MAX_CRON_THREADS2 / 2
ODOO_PROXY_MODETrue
ODOO_LIST_DB / ODOO_WITHOUT_DEMOFalse / False
ODOO_LOG_LEVELinfo

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.

Sources

This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.