
A clean Odoo install on Ubuntu 26.04 LTS takes about 30 minutes. A production-grade one takes a little longer. You need a dedicated system user, a PostgreSQL role with limited privileges, a Python virtual environment, a systemd unit, an Nginx reverse proxy with TLS, and sensible worker limits. Odoo 20 is the current stable release, so this guide uses it.
Most failed Odoo deployments share a pattern. Someone runs apt install odoo on a fresh VPS, sees the login page on port 8069 and calls it done. Two weeks later the server is swapping, PDF reports come out blank, the longpolling chat is dead behind the proxy, and nobody remembers which Python packages were installed globally. The install itself rarely causes the pain. The pain comes from the choices you skip in the first hour.
Ubuntu 26.04 LTS “Resolute Raccoon” was released on April 23, 2026 (Canonical announcement). It brings newer defaults than 24.04, including PostgreSQL 18 and Python 3.13 (qadrLabs overview). Those newer defaults are good news for long-term support. They also mean old tutorials written for Ubuntu 20.04 or 22.04 will break in small, irritating ways.
This walkthrough covers the full path: preparing the server, installing the database, deploying Odoo from source, running it as a service, putting Nginx and HTTPS in front, tuning it, locking it down, and fixing what usually goes wrong. It also covers the shorter package-based route for readers who prefer it.
Choose Your Installation Method
Three realistic options exist. Pick one deliberately, because switching later means a database migration and a reconfigured service.
| Method | Best for | Upgrade effort | Custom addons |
|---|---|---|---|
| Official deb package from Odoo’s repository | Quick, standard deployments | Low (apt) | Fine, via addons_path |
| Source install in a virtualenv | Production, custom modules, multiple instances | Medium (git pull) | Excellent |
| Docker | Containerized stacks, CI/CD | Low | Good |
Odoo publishes an apt repository for Debian-family systems. Its documentation shows the key import and repository line for the 20.0 branch, followed by apt-get update && apt-get install odoo (Odoo 20.0 packaged installers). It works, and it is the fastest path.
The source install is what most experienced admins prefer for serious work. Odoo comes straight from Git, so you pin exact versions and run several isolated instances on one host. You also control exactly which Python packages are installed. This guide focuses on that route and covers the package route briefly near the end.
Prerequisites and Server Sizing
Before touching a terminal, decide what the server needs to carry.
For a small team of up to 10 users on a test or light production system, 2 vCPU, 4 GB RAM and 40 GB of SSD storage is a workable floor. For 25 to 50 concurrent users, plan on 4 vCPU and 8 to 16 GB RAM. Heavy accounting, manufacturing or eCommerce workloads need more, and the database deserves fast NVMe storage.
You will also need:
- A fresh Ubuntu 26.04 LTS server with a sudo-capable, non-root user.
- A domain name with an A record pointing to the server (for example
erp.example.com). - Ports 80 and 443 reachable from the internet, and SSH locked down.
- Roughly 30 minutes of uninterrupted time.
A habit worth building: take a snapshot at the hypervisor or provider level before starting. Rolling back a snapshot beats debugging a half-configured box.
Step 1: Update the System and Set Basics
Start with a fully patched system. Skipping this is how you end up debugging a library version mismatch that a point release already fixed.
sudo apt update && sudo apt full-upgrade -y
sudo timedatectl set-timezone Asia/Jakarta
sudo apt install -y ufw fail2ban curl wget git ca-certificates gnupg
Change the timezone to your own. Odoo stores datetimes in UTC and shows them in each user’s timezone, but the server clock still matters for logs, cron jobs and backups. If a reboot is flagged as required after the upgrade, do it now:
[ -f /var/run/reboot-required ] && sudo reboot
Configure the Firewall Early
Enable the firewall before Odoo exists. That way you never have a window where port 8069 is exposed to the world.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
Notice that 8069 is absent. Odoo will listen on localhost only, and Nginx will be the single public door.
Step 2: Install PostgreSQL
Odoo requires PostgreSQL and nothing else. Ubuntu 26.04 ships PostgreSQL 18 in its default repositories (Ubuntu package listing), so no third-party repository is needed.
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresql
sudo -u postgres psql -c "select version();"
Check Odoo’s documented PostgreSQL support range for your chosen release before going live. Odoo generally supports a wide range of versions and tests against a few, but verifying costs two minutes.
Create the Database Role
Never let Odoo connect as the postgres superuser. Create a dedicated role with just enough power to create databases:
sudo -u postgres createuser --createdb --no-createrole --no-superuser --pwprompt odoo20
The password prompt matters. Peer authentication over the Unix socket works when the Linux user and the PostgreSQL role share a name, which is convenient. A password lets you move the database to another host later without redesigning authentication. Use a long random string from a password manager.
Step 3: Create a Dedicated System User
Running Odoo as root is a serious mistake, and running it as your personal login is only a small improvement. Create a locked-down service account instead:
sudo useradd -m -d /opt/odoo20 -U -r -s /bin/bash odoo20
The -r flag makes it a system account. The home directory at /opt/odoo20 will hold the source, the virtualenv and the data directory. On servers hosting multiple clients, one user and one directory per instance keeps permissions clean and makes a clean teardown trivial.
Step 4: Install System Dependencies
Odoo’s Python packages compile against several C libraries. Missing headers cause the cryptic pip build failures that fill support forums.
sudo apt install -y build-essential python3-dev python3-pip python3-venv python3-wheel \
python3-setuptools libxml2-dev libxslt1-dev libldap2-dev libsasl2-dev libssl-dev \
libffi-dev libjpeg-dev zlib1g-dev libpq-dev libfreetype6-dev liblcms2-dev \
libopenjp2-7-dev libtiff-dev libzip-dev fontconfig xfonts-base xfonts-75dpi \
node-less npm
Package names occasionally change between Ubuntu releases. Some older guides list libtiff5-dev, which no longer exists on current releases, so the generic libtiff-dev above is the safer choice. If apt reports a name it cannot find, run apt search <keyword> and pick the current equivalent rather than hunting for a downloaded .deb. Ubuntu 26.04 ships Python 3.13 as its stock interpreter (RoseHosting guide). Always compare that against the Python version range in the README or setup/ notes of the Odoo branch you clone.
Handle wkhtmltopdf Carefully
Odoo renders PDF reports (invoices, delivery slips) through wkhtmltopdf. This is the dependency that burns people most. The Ubuntu archive version is often built without patched Qt, which breaks headers, footers and page layout.
Check what your release offers first:
apt policy wkhtmltopdf
Odoo recommends the patched-Qt build from the wkhtmltopdf project for correct output. Download the Ubuntu-compatible package of version 0.12.6.1 from the project’s releases page and install it with sudo apt install ./package.deb. Verify with wkhtmltopdf --version, which should mention “with patched qt”. If you install the unpatched archive build, expect reports to render but ignore header and footer settings.
Step 5: Download Odoo 20 from Source
Switch to the service account and clone only the branch you need. Shallow clones save bandwidth and disk.
sudo su - odoo20
git clone https://github.com/odoo/odoo.git --depth 1 --branch 20.0 --single-branch odoo
Other published guides follow this same pattern: a dedicated user, a shallow clone of the version branch, then a virtual environment (Banas Tech guide).
Create the virtual environment and install requirements:
python3 -m venv ~/venv
source ~/venv/bin/activate
pip install --upgrade pip wheel
pip install -r ~/odoo/requirements.txt
Use the system’s default python3 unless the Odoo documentation says otherwise. One published Odoo 20 guide calls python3.12 explicitly, which is not the stock interpreter on 26.04. If you need an older interpreter for compatibility, it is better to build the venv from a clearly installed alternate rather than guess.
Leave the virtualenv and create the directories Odoo needs:
deactivate
mkdir -p ~/custom-addons ~/data
exit
sudo mkdir -p /var/log/odoo
sudo chown odoo20:odoo20 /var/log/odoo
Step 6: Write the Odoo Configuration File
Command-line flags work for tests. Production deserves a config file you can version and audit.
sudo nano /etc/odoo20.conf
[options]
; Never use the default master password
admin_passwd = REPLACE_WITH_A_LONG_RANDOM_STRING
db_host = localhost
db_port = 5432
db_user = odoo20
db_password = YOUR_DB_PASSWORD
db_maxconn = 64
addons_path = /opt/odoo20/odoo/addons,/opt/odoo20/custom-addons
data_dir = /opt/odoo20/data
http_interface = 127.0.0.1
http_port = 8069
gevent_port = 8072
proxy_mode = True
logfile = /var/log/odoo/odoo20.log
log_level = info
workers = 5
max_cron_threads = 2
limit_memory_soft = 2147483648
limit_memory_hard = 2684354560
limit_time_cpu = 600
limit_time_real = 1200
list_db = False
Lock the file down, because it holds credentials:
sudo chown odoo20:odoo20 /etc/odoo20.conf
sudo chmod 640 /etc/odoo20.conf
A few settings deserve explanation:
- admin_passwd protects the database manager screen. Anyone who knows it can drop or download every database. Treat it like a root password.
- http_interface = 127.0.0.1 keeps Odoo off the public network. Only the local Nginx can talk to it.
- proxy_mode = True tells Odoo to trust
X-Forwarded-*headers. Without it, you get wrong redirect URLs, broken HTTPS detection and misleading client IPs in logs. Enable it only when a proxy really sits in front. - list_db = False hides the database selector on the login page. Combine it with
dbfilterif you run multiple databases on one domain.
Those worker numbers are a starting point only. The tuning section explains how to calculate them for your hardware.
Step 7: Create the systemd Service
Systemd gives you start-on-boot, restart-on-failure and journald integration. Create the unit:
sudo nano /etc/systemd/system/odoo20.service
[Unit]
Description=Odoo 20 ERP
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=odoo20
Group=odoo20
ExecStart=/opt/odoo20/venv/bin/python3 /opt/odoo20/odoo/odoo-bin -c /etc/odoo20.conf
Restart=on-failure
RestartSec=5
KillMode=mixed
StandardOutput=journal
SyslogIdentifier=odoo20
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=false
[Install]
WantedBy=multi-user.target
The hardening lines at the bottom cost nothing and shrink the blast radius if a module is ever compromised. If a custom addon needs to write somewhere outside the data directory, loosen ProtectSystem for that case instead of removing everything.
Start it and watch the first boot:
sudo systemctl daemon-reload
sudo systemctl enable --now odoo20
sudo systemctl status odoo20 --no-pager
sudo journalctl -u odoo20 -f
When you see a line indicating the HTTP service is running on 127.0.0.1:8069, the application layer is alive. Test locally with curl -I http://127.0.0.1:8069/web/login, which should return a 200 response.
Step 8: Put Nginx in Front
Exposing Odoo’s built-in server directly is possible but unwise. Nginx handles TLS, compression, buffering of slow clients and static file caching far better.
sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/odoo20
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;
access_log /var/log/nginx/odoo.access.log;
error_log /var/log/nginx/odoo.error.log;
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;
proxy_set_header Host $host;
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_redirect off;
proxy_pass http://odoo;
}
location ~* /web/static/ {
proxy_cache_valid 200 60m;
proxy_buffering on;
expires 864000;
proxy_pass http://odoo;
}
gzip on;
gzip_types text/css text/plain text/xml application/xml application/json application/javascript;
}
Enable the site and validate before reloading:
sudo ln -s /etc/nginx/sites-available/odoo20 /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
The /websocket block is what keeps live chat, notifications and the Discuss app working. Skip it and the interface loads fine while real-time features silently fail. Many a “chat is broken” ticket ends with this missing block. The websocket proxy only reaches the gevent port when workers is greater than zero. In single-process dev mode, everything runs on 8069.
Add HTTPS with Let’s Encrypt
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d erp.example.com --redirect -m you@example.com --agree-tos
sudo systemctl list-timers | grep certbot
Certbot rewrites the server block to add the 443 listener and sets up automatic renewal. Confirm renewal works with sudo certbot renew --dry-run. Afterwards, add HSTS and a few security headers inside the HTTPS server block:
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
Be careful with X-Frame-Options if you embed Odoo forms, such as a website form, inside another site. A blanket deny breaks that legitimate use.
Step 9: Create Your First Database
Browse to https://erp.example.com. With list_db = False, the database manager lives at /web/database/manager. If that route is also blocked, create the database from the shell:
sudo su - odoo20
source ~/venv/bin/activate
~/odoo/odoo-bin -c /etc/odoo20.conf -d mycompany -i base --stop-after-init --without-demo=all
exit
The --without-demo=all flag matters. Demo data in a production database is clutter that is painful to remove later. Afterwards, restart the service and log in. Change the default administrator password right away, and set a proper email, company name and currency before importing any real data.
The Package Route: A Shorter Alternative
If you prefer apt to manage everything, Odoo’s repository gives you a service, a system user and a PostgreSQL role in a single command. The official documentation shows the repository set-up for the 20.0 branch (Odoo documentation):
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/20.0/nightly/deb/ ./' | sudo tee /etc/apt/sources.list.d/odoo.list
sudo apt update && sudo apt install odoo
The configuration then lives at /etc/odoo/odoo.conf, and the service is simply odoo. Apply the same hardening, proxy and worker advice from this guide.
One caution: do not simply install odoo from Ubuntu’s own universe repository. On 26.04 that route installs Odoo 18 (RoseHosting guide), which is probably not what you want. Run apt policy odoo before installing so you know which repository you are pulling from. Odoo’s nightly packages also move daily. For a production system, many admins prefer a pinned source checkout over a nightly package, so an unattended update never surprises a live system.
Troubleshooting Common Errors
Service starts, then dies immediately
Read the actual log rather than guessing: sudo journalctl -u odoo20 -n 100 --no-pager and tail -n 100 /var/log/odoo/odoo20.log. The usual causes are a wrong path in ExecStart, a log directory the service user cannot write to, or a syntax error in the config file.
“FATAL: role ‘odoo20’ does not exist” or password authentication failed
The role was never created, or the password in odoo20.conf differs from the one set in PostgreSQL. Reset it with:
sudo -u postgres psql -c "ALTER ROLE odoo20 WITH PASSWORD 'newpassword';"
pip fails building psycopg2, lxml or python-ldap
This almost always means a missing development header. Reinstall the dependency list from Step 4. pg_config executable not found means libpq-dev is missing. A ldap.h error points to libldap2-dev and libsasl2-dev.
PDF reports are blank or lack headers and footers
Check wkhtmltopdf --version. If it does not say “with patched qt”, install the correct build. Also confirm that the system parameter web.base.url matches your public HTTPS URL. A report that cannot fetch its own CSS from the wrong address renders unstyled.
Chat and notifications do not update
Verify the /websocket location in Nginx points to port 8072, that workers is greater than 0, and that proxy_mode = True. Check with ss -tlnp | grep 8072.
502 Bad Gateway from Nginx
Nginx cannot reach Odoo. Confirm the service is running, then curl -I http://127.0.0.1:8069. Look in /var/log/nginx/odoo.error.log. After a long import or heavy report, a worker may have been killed for exceeding limits, so check the Odoo log for “Virtual memory limit reached” or “CPU time limit exceeded” messages.
Odoo is slow after a few days
Look for swapping with vmstat 1 and free -h. Slow queries show up in PostgreSQL if you enable log_min_duration_statement = 1000 temporarily. Missing indexes on custom fields and runaway cron jobs are the classic culprits. Also check df -h, since a full disk degrades everything quietly.
Mixed content or redirect loops after enabling HTTPS
Nine times out of ten, either proxy_mode is off or the X-Forwarded-Proto header is missing. Fix both, then clear the browser cache and confirm web.base.url uses https. Also set the system parameter web.base.url.freeze to True so Odoo does not rewrite it from a stray request.