Self-hosting Odoo

How to self-host Odoo 18 in production

How to self-host Odoo 18 on Ubuntu 24.04: sizing, PostgreSQL, odoo.conf workers and limits, nginx with websocket, SSL, hardening and a go-live checklist.

CICDoo Engineering Updated 9 min read

The short answer

To self-host Odoo 18 in production, run it on Ubuntu 24.04 with PostgreSQL 15 or 16, multi-process mode (workers set to roughly 2 x CPU cores + 1), and nginx in front terminating TLS and proxying /websocket to port 8072. Set proxy_mode, list_db False, a strong admin_passwd and a dbfilter, then back up the database and the filestore together before you go live.

On this page
  1. The short version
  2. Server sizing
  3. Docker or package install?
  4. Step 1: prepare Ubuntu 24.04
  5. Step 2: PostgreSQL
  6. Step 3: install Odoo 18
  7. Step 4: odoo.conf for production
  8. Step 5: nginx reverse proxy with websocket
  9. Step 6: SSL with certbot
  10. Step 7: the filestore
  11. Step 8: security hardening
  12. Step 9: logging and monitoring
  13. Step 10: cron workers and scheduled actions
  14. Go-live checklist
  15. Doing this with CICDoo

The short version

A production Odoo 18 server is four pieces: the Odoo application server running in multi-process mode, a PostgreSQL database, a filestore directory for attachments, and a reverse proxy (usually nginx) that terminates HTTPS and routes the websocket traffic. Everything else in this guide is about sizing those pieces, configuring them safely, and making sure you can recover when something goes wrong.

Odoo 18 needs Python 3.10 or newer and PostgreSQL 12 or newer. Ubuntu 24.04 LTS ships Python 3.12 and PostgreSQL 16, which is a good, supported combination. The steps below work the same way on Odoo 17 and 19, with the differences noted where they matter.

Server sizing

Odoo's resource use is driven by concurrent users, not total users. A company with 100 named users often has 10 to 20 active at once. Size for the busy hour, then leave headroom for crons, report generation and imports.

Workload vCPU RAM Disk
Small team, up to ~25 concurrent users 2 to 4 8 GB 80 GB SSD
Mid-size, 25 to 75 concurrent users 4 to 8 16 GB 160 GB SSD
Larger, heavy eCommerce or POS 8+ (or split DB onto its own host) 32 GB+ NVMe, sized to data growth

These are starting points, not guarantees. Measure after launch. PostgreSQL benefits most from RAM (page cache) and fast disk; Odoo workers benefit from CPU. Once the database is larger than a few tens of gigabytes or CPU contention shows up, move PostgreSQL to its own machine or a managed database.

Docker or package install?

Both are valid in production. The choice is mostly about how you want to handle upgrades and multiple environments.

Debian package (nightly.odoo.com) Docker (official odoo image) Source checkout (git)
Install effort Low Low Medium
Custom addons Extra path in odoo.conf Mounted volume Extra path in odoo.conf
Multiple versions on one host Awkward Easy Possible with venvs
Enterprise addons Add path manually Mount the repo Add path manually
Patch control Follows nightly builds Pin the image tag or digest Pin a commit

If you run one database on one server, the package is the simplest route. If you run staging and production side by side or several clients per server, containers keep them isolated. The Docker route is covered in detail in the Odoo Docker Compose production guide. The rest of this page uses the package install, and the configuration applies to both.

Step 1: prepare Ubuntu 24.04

Start from a fresh, updated server with a non-root sudo user and SSH key login.

sudo apt update && sudo apt upgrade -y
sudo apt install -y postgresql postgresql-client nginx certbot python3-certbot-nginx ufw
sudo timedatectl set-timezone UTC

Keep the server clock in UTC. Odoo stores datetimes in UTC and converts per user, so a non-UTC system clock causes confusing cron and log timestamps.

Step 2: PostgreSQL

Create a database role for Odoo. It needs CREATEDB if Odoo should create databases, and it must not be a superuser.

sudo -u postgres createuser --createdb --no-superuser --no-createrole odoo
sudo -u postgres psql -c "ALTER USER odoo WITH PASSWORD 'change-me-long-random';"

When Odoo and PostgreSQL share a host, connect over the Unix socket or localhost only, and keep port 5432 closed in the firewall. Tune a few settings in /etc/postgresql/16/main/postgresql.conf for a 16 GB server that also runs Odoo:

shared_buffers = 3GB
effective_cache_size = 8GB
work_mem = 32MB
maintenance_work_mem = 512MB
max_connections = 200
random_page_cost = 1.1

max_connections must cover every Odoo process multiplied by db_maxconn, plus maintenance connections. In practice, keep db_maxconn modest (the default is 64 per process) and check pg_stat_activity under load.

Step 3: install Odoo 18

Add Odoo's signed nightly repository and install the package. It creates an odoo system user, a systemd service and a default config at /etc/odoo/odoo.conf.

wget -q -O - https://nightly.odoo.com/odoo.key | sudo gpg --dearmor -o /usr/share/keyrings/odoo-archive-keyring.gpg
echo 'deb [signed-by=/usr/share/keyrings/odoo-archive-keyring.gpg] https://nightly.odoo.com/18.0/nightly/deb/ ./' | sudo tee /etc/apt/sources.list.d/odoo.list
sudo apt update && sudo apt install -y odoo

PDF reports need wkhtmltopdf with patched Qt (0.12.6.x). The Ubuntu repository version renders headers and footers incorrectly. Install the build from the wkhtmltopdf packaging releases on GitHub and confirm wkhtmltopdf --version reports "with patched qt". Check the official install docs for the current recommendation for your distribution.

Step 4: odoo.conf for production

This is the file that decides whether Odoo survives real traffic. A solid starting point for a 4 vCPU, 16 GB server:

[options]
; security
admin_passwd = a-long-random-master-password
list_db = False
dbfilter = ^erp$
proxy_mode = True

; database
db_host = False
db_port = False
db_user = odoo
db_password = change-me-long-random
db_maxconn = 32

; paths
addons_path = /usr/lib/python3/dist-packages/odoo/addons,/opt/odoo/custom-addons
data_dir = /var/lib/odoo

; processes
workers = 9
max_cron_threads = 2
gevent_port = 8072

; limits
limit_memory_soft = 2147483648
limit_memory_hard = 2684354560
limit_time_cpu = 600
limit_time_real = 1200
limit_request = 65536

; logging
logfile = /var/log/odoo/odoo-server.log
log_level = info

What each block does:

  • workers: setting this above 0 switches Odoo to multi-process (prefork) mode, which you need in production. The usual formula is (CPU cores x 2) + 1, and a rule of thumb from Odoo's docs is about 6 concurrent users per worker. Check the memory side too: heavy workers can use around 1 GB each under load, so workers x limit_memory_soft should fit in RAM left over after PostgreSQL.
  • max_cron_threads: cron workers run on top of HTTP workers. Two is fine for most sites; raise it if scheduled actions queue up.
  • gevent_port: the long-polling and websocket process listens here (8072 by default). Before Odoo 16 this option was called longpolling_port. In Odoo 17 and later the route is /websocket.
  • limit_memory_soft / hard: a worker exceeding the soft limit is recycled after its current request; one exceeding the hard limit is killed immediately. The values above are the defaults (2048 MiB and 2560 MiB).
  • limit_time_cpu / real: the defaults (60 and 120 seconds) are tight for big reports and imports. Raise them deliberately, not to infinity, because they protect you from runaway requests.
  • proxy_mode: makes Odoo trust X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto from nginx, so client IPs and generated URLs are correct. Only enable it behind a proxy you control.
  • list_db = False hides the database manager and the database selector. dbfilter is a regular expression that restricts which databases a hostname can open. The example above pins a single database named erp; using %d in the pattern substitutes the first subdomain of the request, so erp.example.com opens the database erp.
  • admin_passwd: the master password that protects database create, drop, backup and restore operations. Use a long random value. Odoo stores it hashed after first use.

Put custom and third-party modules in their own directory, owned by the odoo user, and list it in addons_path. Never edit core addons in place.

Step 5: nginx reverse proxy with websocket

nginx terminates HTTPS, serves as the single public entry point, and sends /websocket to the gevent port. Without that route, Discuss, live notifications and some POS features stop updating.

upstream odoo { server 127.0.0.1:8069; }
upstream odoochat { server 127.0.0.1:8072; }

map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}

server {
  listen 80;
  server_name erp.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 443 ssl;
  http2 on;
  server_name erp.example.com;

  ssl_certificate     /etc/letsencrypt/live/erp.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/erp.example.com/privkey.pem;

  client_max_body_size 200m;
  proxy_read_timeout 720s;
  proxy_connect_timeout 720s;
  proxy_send_timeout 720s;

  proxy_set_header X-Forwarded-Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Real-IP $remote_addr;

  location /websocket {
    proxy_pass http://odoochat;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
  }

  location / {
    proxy_pass http://odoo;
    proxy_redirect off;
  }

  gzip on;
  gzip_types text/css text/plain application/json application/javascript text/xml;
}

Keep ports 8069 and 8072 bound to localhost or blocked in the firewall, so all traffic goes through nginx.

Step 6: SSL with certbot

Point the DNS A (and AAAA) record at the server, then request a certificate:

sudo certbot --nginx -d erp.example.com
sudo systemctl status certbot.timer

Certbot installs a systemd timer that renews certificates before they expire. Run sudo certbot renew --dry-run once to confirm renewal works, and monitor certificate expiry externally, because a failed renewal is silent until users see a browser warning.

Step 7: the filestore

Attachments, images and generated documents live on disk, not in PostgreSQL, under data_dir/filestore/<database_name>. The database only stores references to those files. That has two consequences:

  1. A backup that contains only a pg_dump is incomplete. You need the database and the matching filestore, taken close together in time.
  2. The filestore must be on durable storage with enough room to grow. For multi-server setups it must be shared (NFS or similar) or replaced with an object storage module.

The full procedure, including restore drills, is in the Odoo backups to S3 guide.

Step 8: security hardening

  • Firewall: allow 22, 80 and 443 only. sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full' && sudo ufw enable.
  • SSH: key-only login, no root login, and fail2ban or an allowlist for port 22.
  • Database manager: list_db = False plus a strong admin_passwd. Optionally block /web/database in nginx entirely.
  • Brute force: add an nginx limit_req zone on /web/login, and enable two-factor authentication for internal users in Odoo settings.
  • Least privilege: the Odoo service runs as the unprivileged odoo user, and the PostgreSQL role is not a superuser.
  • Updates: apply OS security updates automatically (unattended-upgrades) and update Odoo on a schedule after testing on staging.
  • Outgoing mail: configure SPF, DKIM and DMARC for the sending domain, or invoices land in spam.

Step 9: logging and monitoring

With logfile set, rotate logs with logrotate (the Debian package ships a config) or log to the journal by leaving logfile empty and reading journalctl -u odoo. Watch for three things in particular: MemoryError or workers killed for exceeding limit_memory_hard, virtual real time limit messages from long requests, and cron jobs that fail repeatedly. Add an external uptime check against /web/health, and track disk space on both the database volume and the filestore.

Step 10: cron workers and scheduled actions

In multi-process mode, cron runs in its own processes controlled by max_cron_threads. If you run several Odoo servers against one database, only some of them should run crons, set max_cron_threads = 0 on the others. Scheduled actions that send mail or call external APIs will run on any restored copy of your database too, which is why staging copies must be neutralised (covered in the backup guide).

Go-live checklist

  • [ ] workers above 0, sized to CPU and RAM, and verified under a load test
  • [ ] proxy_mode = True, list_db = False, strong admin_passwd, correct dbfilter
  • [ ] nginx routes /websocket to port 8072, and chat updates live in the browser
  • [ ] HTTPS works, HTTP redirects, certbot renew --dry-run passes
  • [ ] Ports 5432, 8069 and 8072 closed from the internet
  • [ ] wkhtmltopdf with patched Qt, a test invoice prints with header and footer
  • [ ] Outgoing and incoming mail servers configured, SPF, DKIM and DMARC set
  • [ ] Automated backups of database and filestore, stored off the server
  • [ ] A restore tested on another machine, with the time it took written down
  • [ ] Uptime check, disk alerts and log review in place
  • [ ] Staging environment for testing module updates before production

Doing this with CICDoo

Every step above is something you can own yourself, and many teams do. If you would rather not maintain the nginx configs, certificates, container builds and backup scripts by hand, CICDoo does this on Linux servers you own: you connect a server over SSH, link a GitHub or GitLab repository, and each branch deploys as its own Odoo instance in a Docker container, with nginx and automatic SSL for your domains, scheduled backups to S3-compatible storage and uptime and resource monitoring with alerts. It supports Community and Enterprise, 11.0 to the latest release, plus master. You pay your cloud provider for the servers, and the servers and data stay yours. See how stages work or talk to us.

Part of Self-hosted Odoo, done right

FAQ

Questions, answered

What are the minimum server requirements for Odoo 18?

For a small team, 2 vCPU and 4 to 8 GB of RAM with SSD storage runs Odoo 18 and PostgreSQL on one server. Production workloads with 25 or more concurrent users are more comfortable on 4 or more vCPU and 16 GB of RAM. Odoo 18 needs Python 3.10+ and PostgreSQL 12+, and PostgreSQL 15 or 16 is recommended.

Does Odoo 18 run on Ubuntu 24.04?

Yes. Ubuntu 24.04 ships Python 3.12 and PostgreSQL 16, both supported by Odoo 18. Install Odoo from the nightly.odoo.com repository or use the official Docker image, and install wkhtmltopdf 0.12.6 with patched Qt for PDF reports.

How many workers should I set in odoo.conf?

Start with (CPU cores x 2) + 1, then check that workers multiplied by limit_memory_soft fits in the RAM left after PostgreSQL. A rough rule is one worker per 6 concurrent users. Set max_cron_threads separately, usually to 2.

Why does Odoo chat or Discuss not update behind nginx?

Your proxy is not routing the websocket. In Odoo 17 and later, send the /websocket location to the gevent port (8072 by default) with the Upgrade and Connection headers set, and make sure workers is above 0 so the gevent process starts.

What does proxy_mode do in Odoo?

proxy_mode = True tells Odoo to trust the X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Proto headers set by your reverse proxy. Without it, Odoo sees every request as coming from 127.0.0.1 over HTTP and can generate wrong URLs. Enable it only when Odoo is reachable exclusively through a proxy you control.

Should I use Docker or the Odoo package in production?

Both work. The Debian package is simplest for one database on one server. Docker is easier when you run several versions, staging and production, or several clients on one host, because each instance is isolated and upgrades are image changes.

Run Odoo like this, without doing it by hand

CICDoo turns every step in this guide into a push to Git, on servers you own. Talk to an engineer about your setup.

No per-seat fees. Your servers stay yours.