Deploying Odoo from scratch : if you can install WordPress, you can do this
Odoo isn’t complicated. You’re just made to believe it is.
An everyday scene. You’re looking to install Odoo on a server. You land on the official docs: they nudge you toward Odoo.sh (their SaaS platform) or, failing that, a .deb package that installs everything as a black box. You go looking for a community tutorial: 150 lines of docker-compose.yml, three named volumes, a bridge network, and a restart: always that smells like a band-aid on a bullet wound. You ask an integrator for a quote: €15,000 for “setting up the environment,” €800/day for “onboarding support.”
And you think to yourself: Odoo is complicated.
It isn’t.
Odoo’s architecture is a WordPress that speaks PostgreSQL and Python. An interpreter, a database, a folder of files, a reverse proxy in front. That’s it. If you’ve already set up a WordPress on a VPS (and if you’re reading this article, you probably have), you already have every skill you need.
Odoo’s complexity isn’t technical. It’s commercial. The vendor (Odoo SA) has every interest in steering you toward its SaaS or its certified partners. Integrators have every interest in keeping a barrier to entry high enough to justify their rates. The documentation has every interest in making manual installation opaque enough that you give up and reach for your credit card.
It’s exactly the same mechanism as the WordPress agencies of 2015: shops that billed €10,000 for a site you could build in an afternoon with a premium theme and three plugins. The product wasn’t complicated. The rent was.
This article will show you, command by command, that installing Odoo on a bare server is exactly as simple as installing WordPress. No more, no less.
The WordPress / Odoo parallel: everything you already know
Before we open a terminal, let’s set the table. Here’s what you already know, and its exact equivalent in the Odoo ecosystem:
| Concept | WordPress | Odoo |
|---|---|---|
| Language | PHP | Python |
| Database | MySQL / MariaDB | PostgreSQL |
| Web server | Apache / Nginx → PHP-FPM | Nginx → reverse proxy to odoo-bin (port 8069) |
| Extensions | wp-content/plugins/ | addons/ (modules) |
| Themes | wp-content/themes/ | Web theme modules (website_theme_*) |
| Uploaded files | wp-content/uploads/ | filestore/ (attachments, binaries, images) |
| Translations | wp-content/languages/ (.po / .mo) | i18n/ in each module (.po / .mo, same format) |
| Main config | wp-config.php | odoo.conf |
| CLI | wp-cli | odoo-bin (same instincts) |
| DB export/import | wp db export / wp db import | pg_dump / pg_restore + odoo-bin -u |
| Scheduled tasks | wp-cron / system cron | Odoo workers + ir.cron (internal recurring tasks) |
| Hooks / extensibility | add_action() / add_filter() | @api.depends / model inheritance (_inherit) |
| Updating extensions | wp plugin update --all | odoo-bin -u module_name -d my_db |
| SSL | Certbot / Let’s Encrypt | Certbot / Let’s Encrypt (identical) |
| System service | systemctl restart php8.3-fpm | systemctl restart odoo |
| Master password | Doesn’t exist | admin_passwd in odoo.conf (protects /web/database/manager) |
The bottom line is this: Odoo is no more “special” than WordPress + WooCommerce + a CRM + an HR module + a stock manager. It’s just coherent by design: everything comes from the same vendor, everything shares the same ORM, the same database, the same views. Where WordPress stacks plugins from three different authors that step on each other, Odoo integrates natively. That’s its strength. And the two worlds aren’t sealed off from each other: WP4Odoo syncs WooCommerce and Odoo bidirectionally, proof that the technical bridge between the two ecosystems already exists. But on the deployment side, the move is the same.
Why you’re made to believe it’s complicated
The official docs steer you elsewhere
Head to odoo.com/page/download. The first option is Odoo.sh, their managed PaaS. The second is a .deb / .rpm that does everything in one command and teaches you nothing. Installing from source? Buried in the developer docs, drowned in Docker prerequisites. The message is clear: don’t touch the infrastructure, let us handle it.
It’s a perfectly rational commercial choice. Odoo SA makes money on Odoo.sh and on Enterprise licenses. I’ve laid out the four Odoo hosting options in a dedicated article: Online, Odoo.sh, on-premise, third-party hosting. What you’re reading here is the fifth option, the one nobody presents to you: bare metal, by hand, with no middleman. A sysadmin who installs Community on bare metal on their own server is a lost customer.
Integrators sell opacity
A certified Odoo integrator bills between €800 and €1,500 a day. Their business model rests on an information asymmetry: you don’t know how, so you pay someone who does. The more esoteric the “know-how” looks, the more the rate is justified.
It’s exactly the same mechanism as the WordPress agencies that sold a €15,000 site built with Elementor and 12 premium plugins. The role of the “certified Odoo experts” is the same as that of the “Senior WordPress consultants” of 2016: the more you complicate things, the more you justify your €120/hr.
I’m not saying every integrator is a charlatan. There are complex Odoo projects that justify outside help (multi-company migrations, heavy custom development, EDI integrations). But for a standard SMB deployment? You can do it yourself. And you should do it yourself, if only to understand what’s running on your server. It was actually while installing Odoo from scratch to build my first module (an AI-powered supplier-invoice digitization pipeline, on Community, without the OCR brick locked behind Enterprise) that I realized just how artificial the barrier to entry was.
The requirements.txt is scary (wrongly so)
When you open Odoo’s requirements.txt, you see 80 Python dependencies. It’s intimidating. Except that:
- Half of them are standard libraries (XML parsing, image manipulation, cryptography).
- A
pip install -r requirements.txtinside avenvinstalls them in 30 seconds. - It’s exactly the same reality as a serious WordPress stack: PHP-FPM, the
php-gd,php-xml,php-mbstring,php-curl,php-zipextensions, Redis, OPcache… Nobody says WordPress is “complicated” because you have to installphp-intl.
PostgreSQL isn’t harder than MySQL
It’s the other bogeyman. “Odoo uses PostgreSQL, that’s different.” It isn’t. The commands change, the logic is identical:
| Operation | MySQL (WordPress) | PostgreSQL (Odoo) |
|---|---|---|
| Create a user | CREATE USER 'wp'@'localhost' IDENTIFIED BY 'pass'; | sudo -u postgres createuser -P odoo |
| Create a database | CREATE DATABASE wordpress; | createdb -O odoo my_db |
| Grant privileges | GRANT ALL ON wordpress.* TO 'wp'@'localhost'; | The -O (owner) flag is enough: the owner has all privileges |
| Export | mysqldump wordpress > dump.sql | pg_dump my_db > dump.sql |
| Import | mysql wordpress < dump.sql | psql my_db < dump.sql |
| Restart | systemctl restart mysql | systemctl restart postgresql |
Same move, same logic, slightly different syntax. If you know how to create a MySQL database for WordPress, you know how to create a PostgreSQL database for Odoo. The learning curve is 15 minutes.
The real difficulty is the SaaS → on-premise migration
If there’s one point where the complexity isn’t manufactured, this is it. Odoo Online runs on the latest version, often ahead of what your target server runs, and Odoo provides no downgrade tool. Schema incompatibility, dependence on support to obtain an export in the right version, critical tables that change between releases: migrating a SaaS database to an on-premise server is a project in its own right, whereas in WordPress it’s an XML export and a wp import. I’ve detailed the implications of each hosting option in my dedicated comparison. It’s also an argument for starting on bare metal directly rather than going through SaaS “just to test.”
Bare-metal deployment, step by step
Here we go. Four blocks, in order. By the end, you’ll have a working Odoo 18 over HTTPS.
1. Prepare the system and PostgreSQL
# System update
sudo apt update && sudo apt upgrade -y
# Install PostgreSQL
sudo apt install -y postgresql
# Create the PostgreSQL user for Odoo
sudo -u postgres createuser -s odoo
# Check
sudo -u postgres psql -c "\du"
Code language: PHP (php)
This is the exact equivalent of your sudo apt install mariadb-server + CREATE USER 'wordpress'@'localhost'. If you already host on Debian, you’re on familiar ground.
The -s flag grants the superuser role. In production you can restrict it, but for a first deployment it keeps things simple. Just like when you give GRANT ALL to your WordPress user.
2. Create a system user, a Python venv, and install the dependencies
# Install system dependencies
sudo apt install -y python3-dev python3-venv python3-pip \
libxml2-dev libxslt1-dev libpq-dev libldap2-dev libsasl2-dev \
libssl-dev libjpeg-dev zlib1g-dev libfreetype6-dev \
node-less npm git
# Install wkhtmltopdf (specific version required by Odoo)
# Odoo 18 needs wkhtmltopdf 0.12.6.1 with the Qt patch
sudo apt install -y wkhtmltopdf
# Create the system user
sudo useradd -m -d /opt/odoo -s /bin/bash odoo
# Clone Odoo 18
sudo -u odoo git clone https://github.com/odoo/odoo.git \
--depth 1 --branch 18.0 /opt/odoo/odoo-server
# Create the venv and install the Python dependencies
sudo -u odoo python3 -m venv /opt/odoo/venv
sudo -u odoo /opt/odoo/venv/bin/pip install wheel
sudo -u odoo /opt/odoo/venv/bin/pip install -r /opt/odoo/odoo-server/requirements.txt
Code language: PHP (php)
Let’s break down what you just did:
- The
lib*-devpackages: the equivalent of PHP extensions (php-gd,php-xml,php-curl…). System libraries that Python uses to compile its modules. No more esoteric than that. wkhtmltopdf: the tool that generates PDFs (invoices, reports). It’s THE classic Odoo trap; more on that in part VII.- The
venv: an isolated Python environment. The conceptual equivalent of aphp-fpmpool dedicated to one site. It avoids version conflicts between projects. - The
git clone --depth 1: you grab Odoo’s source code, branch 18.0, without the Git history. It’s like downloading WordPress and unzipping it into/var/www/.
That scary requirements.txt? One pip install command and it’s done. Exactly like sudo apt install php-gd php-xml php-mbstring php-curl php-zip php-intl.
3. First launch, creating the database
# Launch Odoo in "first run" mode
sudo -u odoo /opt/odoo/venv/bin/python /opt/odoo/odoo-server/odoo-bin \
--addons-path=/opt/odoo/odoo-server/addons \
-d my_db \
--without-demo=all \
-i base
Code language: PHP (php)
Odoo starts up, creates the my_db database in PostgreSQL, installs the base module, and listens on http://localhost:8069.
This is your equivalent of “opening http://mysite.com/wp-admin/install.php and clicking Install.” Except here it happens on the CLI. The result is the same: a working application with a login screen.
Open http://your-ip:8069 in a browser. You should see the Odoo login screen. Default login: admin / admin.
Tip: The
--without-demo=allflag avoids loading demo data. In production, that’s what you want. In dev, drop it to get test data.
4. Nginx reverse proxy + Systemd + SSL
The Systemd service
Create /etc/systemd/system/odoo.service:
[Unit]
Description=Odoo 18
After=postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=odoo
Group=odoo
ExecStart=/opt/odoo/venv/bin/python /opt/odoo/odoo-server/odoo-bin \
--config=/etc/odoo/odoo.conf
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Code language: JavaScript (javascript)
sudo systemctl daemon-reload
sudo systemctl enable odoo
sudo systemctl start odoo
This is your php8.3-fpm.service, Odoo edition. Same logic: a process running in the background, managed by systemd, that restarts automatically on a crash.
The Nginx reverse proxy
server {
listen 80;
server_name odoo.mydomain.com;
# HTTPS redirect (after Certbot)
# return 301 https://$server_name$request_uri;
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;
client_max_body_size 200m;
location / {
proxy_pass http://127.0.0.1:8069;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $host;
}
# Security headers: same as for a hardened WordPress
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
location /longpolling {
proxy_pass http://127.0.0.1:8072;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* /web/static/ {
proxy_cache_valid 200 90m;
proxy_buffering on;
expires 864000;
proxy_pass http://127.0.0.1:8069;
}
}
Code language: PHP (php)
sudo ln -s /etc/nginx/sites-available/odoo /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
If you’ve already configured an Nginx vhost for WordPress with fastcgi_pass, you’ll recognize the structure. The only difference: instead of fastcgi_pass to PHP-FPM, it’s a proxy_pass to port 8069. Nginx no longer talks to a PHP interpreter but to a Python HTTP server. The rest, headers, timeouts, static-asset caching, is identical.
The /longpolling block is specific to Odoo: it’s the websocket that handles real-time notifications (instant messages, view updates). It listens on port 8072.
SSL with Certbot
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d odoo.mydomain.com
Code language: CSS (css)
Strictly identical to what you do for WordPress. Certbot edits the vhost, adds the certificate, sets up automatic renewal. Nothing Odoo-specific. Then remember to configure your HTTP security headers, the same discipline as on your WordPress sites.
odoo.conf decoded: Odoo’s wp-config.php
Odoo’s configuration file is its wp-config.php. Create it at /etc/odoo/odoo.conf:
sudo mkdir -p /etc/odoo
sudo chown odoo:odoo /etc/odoo
Here are the three profiles, line by line.
Profile 1: Local dev
[options]
; === Database ===
db_host = localhost
db_port = 5432
db_user = odoo
db_password = False
; False = peer authentication (no password, like MySQL locally)
; === Paths ===
addons_path = /opt/odoo/odoo-server/addons,/opt/odoo/custom-addons
data_dir = /opt/odoo/.local/share/Odoo
; === Server ===
http_port = 8069
workers = 0
; workers = 0: single-threaded mode, perfect for dev
; Equivalent of the PHP built-in server (php -S localhost:8000)
; === Proxy ===
proxy_mode = False
; No reverse proxy in local dev
; === Security ===
admin_passwd = my_master_password
; Protects /web/database/manager (database creation/deletion)
; Doesn't exist in WordPress, a security bonus
; === Logs ===
log_level = debug
logfile = False
; False = console output (handy in dev)
Code language: PHP (php)
Profile 2: Small production
[options]
; === Database ===
db_host = localhost
db_port = 5432
db_user = odoo
db_password = a_real_password
db_name = my_db_prod
; db_name: restricts Odoo to a single database (security)
dbfilter = ^my_db_prod$
; Prevents access to other databases via the URL
; Equivalent of setting DB_NAME in wp-config.php
; === Paths ===
addons_path = /opt/odoo/odoo-server/addons,/opt/odoo/custom-addons
data_dir = /var/lib/odoo
; === Server ===
http_port = 8069
workers = 4
; workers > 0: multi-process mode
; Rule: (2 × CPU) + 1
; Equivalent of pm.max_children in php-fpm.conf
max_cron_threads = 2
; Threads dedicated to scheduled tasks (ir.cron)
; Equivalent of wp-cron, but managed internally
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_time_cpu = 600
limit_time_real = 1200
; Resource limits per worker, same logic as
; memory_limit and max_execution_time in php.ini
; === Proxy ===
proxy_mode = False
; Not behind Nginx yet? Leave it at False
; === Security ===
admin_passwd = a_very_long_master_password
list_db = False
; list_db = False: hides the database list on the login screen
; Basic but important security
; === Logs ===
log_level = warn
logfile = /var/log/odoo/odoo-server.log
log_handler = :WARNING
Code language: PHP (php)
The odoo.conf file holds the database password in clear text. Lock it down:
sudo chmod 640 /etc/odoo/odoo.conf
sudo chown root:odoo /etc/odoo/odoo.conf
Sysadmin standard, the same instinct as for a wp-config.php in production.
Profile 3: Behind Nginx / HTTPS
Start from profile 2 and add:
; === Proxy ===
proxy_mode = True
; CRITICAL: without this, Odoo doesn't read the X-Forwarded-* headers
; and generates URLs in http:// instead of https://
; It's like set_real_ip_from in Nginx for WordPress
Code language: PHP (php)
It’s the parameter everyone forgets and that causes 80% of the “my Odoo is on HTTP even though I have Certbot” problems. proxy_mode = True tells Odoo: “I’m behind a reverse proxy, trust the X-Forwarded-For and X-Forwarded-Proto headers.” Without it, redirects, links in emails, the URLs in PDF reports: everything will be in http://.
wp-config.php ↔ odoo.conf mappings
wp-config.php | odoo.conf | Role |
|---|---|---|
DB_HOST | db_host | Database host |
DB_NAME | db_name / dbfilter | Target database |
DB_USER | db_user | DB user |
DB_PASSWORD | db_password | DB password |
WP_DEBUG | log_level = debug | Debug mode |
WP_CONTENT_DIR | data_dir | Files folder |
ABSPATH | addons_path | Code path |
| — | admin_passwd | Master password (doesn’t exist in WP) |
| — | proxy_mode | Trust the reverse proxy |
The filestore: Odoo’s wp-content/uploads/
In WordPress, when you upload an image or a PDF, it lands in wp-content/uploads/2026/02/. In Odoo, it’s the filestore, and the concept is rigorously identical.
Where it lives
/var/lib/odoo/filestore/my_db/
├── 00/
│ ├── 00a1b2c3d4e5f6...
│ └── 00f7e8d9c0b1a2...
├── 01/
│ └── 01abc123def456...
├── ...
└── ff/
Code language: JavaScript (javascript)
Odoo stores files with a SHA-1 hash as the name, organized into subfolders by the first two characters of the hash. It’s less readable than uploads/2026/02/photo.jpg, but it’s more efficient: no name collisions, no encoding issues, automatic deduplication (two identical attachments = a single physical file).
The path is set by data_dir in odoo.conf. By default:
- In dev:
~/.local/share/Odoo/filestore/ - In production:
/var/lib/odoo/filestore/(if you’ve setdata_dir = /var/lib/odoo)
The backup is database + filestore
This is the critical rule, exactly like with WordPress:
| WordPress | Odoo |
|---|---|
mysqldump wordpress > dump.sql | pg_dump my_db > dump.sql |
tar czf uploads.tar.gz wp-content/uploads/ | tar czf filestore.tar.gz /var/lib/odoo/filestore/my_db/ |
| Both are required | Both are required |
A SQL dump without the filestore is a database that references files that no longer exist. Invoices without a logo, products without a photo, empty attachments. Exactly like a WordPress dump without wp-content/uploads/.
In practice, a one-liner does the job:
pg_dump my_db | gzip > dump_$(date +%Y%m%d).sql.gz && tar czf filestore_$(date +%Y%m%d).tar.gz /var/lib/odoo/filestore/my_db/
Code language: JavaScript (javascript)
Automate it with cron, test the restore monthly, just like with WordPress.
Restoring an Odoo database
Through the web interface (the simplest):
- Go to
https://odoo.mydomain.com/web/database/manager - Enter the master password (
admin_passwdfromodoo.conf) - Click “Restore Database”
- Upload the
.zip(which contains the SQL dump + the filestore + amanifest.json) - Choose “This database is a copy” (neutralizes emails and crons, like a WordPress staging site)
Through the terminal (for large files or automation):
# Create the database
sudo -u postgres createdb -O odoo my_db_restored
# Import the dump
sudo -u postgres psql my_db_restored < dump.sql
# Copy the filestore
sudo cp -r filestore_backup/ /var/lib/odoo/filestore/my_db_restored/
sudo chown -R odoo:odoo /var/lib/odoo/filestore/my_db_restored/
# Restart Odoo
sudo systemctl restart odoo
Code language: PHP (php)
413 error? If the web restore fails on large databases, increase
client_max_body_sizein your Nginx config. It’s the same problem as uploading large media in WordPress.
The real traps (not the ones you’re sold)
Now that we’ve demystified the deployment, let’s talk about the real problems. Not the ones integrators invent to justify their invoices: the ones you’ll actually run into.
wkhtmltopdf: the only real technical trap
Odoo uses wkhtmltopdf to generate PDFs (invoices, quotes, reports). The problem: Odoo requires a specific version with a Qt patch, and the one in the Debian/Ubuntu repos isn’t always the right one.
If your PDFs look ugly (no CSS, broken layout, missing headers), it’s almost always this. Odoo 18 requires wkhtmltopdf 0.12.6.1 (patched Qt). Check with wkhtmltopdf --version and test an invoice PDF after installation.
# Check the installed version
wkhtmltopdf --version
# If the version isn't the right one, install it manually
# Check the recommended version on Odoo's GitHub
wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb
sudo apt install -y ./wkhtmltox_0.12.6.1-3.bookworm_amd64.deb
Code language: PHP (php)
It’s the only Odoo component that needs special attention to its version. The WordPress equivalent would be a specific version of libgd for image processing, except that never happens in practice.
Workers: the equivalent of PHP-FPM processes
With workers = 0 (dev mode), Odoo handles one request at a time. In production, you need to switch to multi-process mode:
workers = 4 # (2 × number of CPUs) + 1
max_cron_threads = 2 # Threads for scheduled tasks
Code language: PHP (php)
Concretely: 2 CPUs → workers = (2×2)+1 = 5. 4 CPUs → workers = (2×4)+1 = 9.
It’s exactly the logic of pm.max_children in the PHP-FPM config:
| PHP-FPM | Odoo | Role |
|---|---|---|
pm.max_children = 10 | workers = 4 | Number of parallel processes |
pm.max_requests = 500 | limit_time_real = 1200 | Limit per request |
memory_limit = 256M | limit_memory_soft = 2G | Max RAM per process |
If Odoo crawls with several users, it’s almost always a workers problem, not an “Odoo complexity” problem.
File permissions: the same fight as WordPress
# The odoo user must own its files
sudo chown -R odoo:odoo /opt/odoo/
sudo chown -R odoo:odoo /var/lib/odoo/
sudo chown -R odoo:odoo /var/log/odoo/
# Config files readable by odoo only
sudo chmod 640 /etc/odoo/odoo.conf
Code language: PHP (php)
It’s the chown -R www-data:www-data /var/www/wordpress/ you know by heart. Same instinct, same logic.
Updating modules: Odoo’s wp plugin update
# Update a specific module
sudo -u odoo /opt/odoo/venv/bin/python /opt/odoo/odoo-server/odoo-bin \
-u module_name -d my_db --stop-after-init
# Update all modules (after a git pull)
sudo -u odoo /opt/odoo/venv/bin/python /opt/odoo/odoo-server/odoo-bin \
-u all -d my_db --stop-after-init
Code language: PHP (php)
The --stop-after-init flag makes Odoo stop after the update instead of staying live. It’s the equivalent of wp plugin update contact-form-7, a bit more verbose, but the same result.
Community vs Enterprise: where the line is
This article installs Community, but the question always comes up. In short: Community broadly covers the scope of an SMB getting started, especially when rounded out with the OCA modules. Enterprise is a mature product that’s worth it when your needs go beyond that scope. I’ve detailed the line between the two in my comparison of Odoo hosting options. What matters here is that bare-metal deployment works for both editions: the move is identical.
Take back control
You’ve just read about 3,000 words. And in those 3,000 words, there’s nothing you don’t already know how to do.
Installing PostgreSQL is installing MySQL. Creating a Python venv is configuring PHP-FPM. Putting an Nginx reverse proxy in front of odoo-bin is putting a reverse proxy in front of anything. Configuring odoo.conf is filling out wp-config.php. Backing up database + filestore is backing up database + uploads.
The only difference between “I know how to install WordPress” and “I know how to install Odoo” is that someone convinced you the second sentence was false.
Odoo on-premise with no Docker, no integrator, and no bullshit is possible. The real value isn’t in the tool. It never was in the tool. It’s in what you do with it in your line of work.