What you will set up
Odoo is a suite of business apps that share one database: CRM, sales, invoicing, contacts, calendar, and many more, such as inventory, purchasing, projects and a website builder. You install the apps you need, and a lead in the CRM becomes a quotation, an order and an invoice without retyping anything.
Odoo is made by Odoo S.A., a company in Belgium, and comes in two editions. Community, which this guide installs, is open source under the LGPLv3 licence and free to run. Enterprise is proprietary and sold per user by subscription. It adds, among other things, the accounting reports (balance sheet, profit and loss, the VAT report), bank synchronisation, Studio, Documents, Sign, Helpdesk, Planning, Payroll, the mobile apps, Odoo's support and Odoo's upgrade service between major versions. In Community, the accounting app is called Invoicing: invoices, bills, payments, journals and the chart of accounts, but no financial reports. In our tests, Odoo reported to Odoo S.A. once a week and sent the email domains of new CRM leads to Odoo's servers; step 11 shows what it sent and switches both off.
If you mainly need accounting for a Swedish company, look at ERPNext as well. It is open source in full, including its accounting reports, and comes with the Swedish BAS chart of accounts. Odoo is easier to start with, has a broader set of apps and a polished CRM, but its reports and several apps are in the paid edition.
Here Odoo runs from its official image, published by Odoo S.A., as a pod under a user of its own called odoo, with PostgreSQL for its data, behind Caddy from the Podman guide.
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- Odoo 20.0 started with PostgreSQL 18, with four web workers sized for 8 GB. The database manager answered "The database manager has been disabled by the administrator", and a login with
adminandadminfailed. - A company was set up in Sweden. Odoo installed the Swedish localisation by itself, with the chart of accounts, SEK and VAT at 25, 12, 6 and 0 percent.
- A lead was created in the CRM and converted to an opportunity, a quotation for it was confirmed and invoiced, and the invoice showed 25 % VAT, 2,500.00 kr on 10,000.00 kr, both on screen and in its PDF.
- Odoo logged each login with the visitor's real address, over IPv4 and IPv6, and ignored a forged
X-Forwarded-Forheader. Its live updates connected over a websocket through Caddy. - An invitation and a password reset arrived through a test mail server on port 587 with STARTTLS. An email to the Sales team's address,
info@, fetched from a test IMAP server, became an opportunity. The interface and the invoice were shown in Swedish. - A capture of the server's traffic showed the weekly report to
services.odoo.comand the lead data toiap-services.odoo.com, and neither after the changes in step 11. - Odoo was restored from a backup into empty volumes, updated to a newer build of 20.0, and everything came back by itself after a reboot.
The pod used about 950 MB of memory after an hour of use, 890 MB of it for Odoo's workers and 60 MB for PostgreSQL, and about 320 MB right after a reboot.
Before you start
You need:
- a server set up as in the Podman guide, with Caddy running, and at least 4 GB of memory;
- an A record and an AAAA record for
odoo.example.compointing at your server; - for email, an account with your email provider that may send over SMTP.
The examples use odoo.example.com for Odoo, 203.0.113.10 for your server's IPv4 address, you@example.com for your own login and odoo@example.com for the address Odoo sends from. Replace them throughout.
1. Create the user
As root:
useradd -m -s /bin/bash odoo
loginctl enable-linger odoo
machinectl shell odoo@
Everything from now on runs as odoo, except step 6.
2. Create the secrets
openssl rand -hex 24 | tr -d '\n' | podman secret create odoo-db-password -
openssl rand -base64 24 | tr -d '\n' | podman secret create odoo-master-password -
podman secret inspect --showsecret --format '{{.SecretData}}' odoo-master-password; echo
The first is the password for PostgreSQL, which only Odoo uses. The second is Odoo's master password, which guards the database manager: the page where anyone who has it can create, copy, back up and delete whole databases. Odoo's default master password is admin. Step 3 switches the database manager off altogether, but keep the master password in your password manager, so the manager is not left open with admin if you ever switch it on.
3. Write Odoo's configuration
Create ~/odoo.conf:
[options]
data_dir = /var/lib/odoo
; Odoo 20 listens only on 127.0.0.1 inside the container by default.
http_interface = 0.0.0.0
db_host = 127.0.0.1
db_port = 5432
db_user = odoo
; Only the database called odoo, and no database manager.
dbfilter = ^odoo$
list_db = False
; Behind Caddy: trust its X-Forwarded-* headers.
proxy_mode = True
; Sized for 2 vCPU and 8 GB.
workers = 4
max_cron_threads = 1
limit_memory_soft = 629145600
limit_memory_hard = 1677721600
limit_time_cpu = 600
limit_time_real = 1200
; Do not report to Odoo S.A. every week (step 11).
publisher_warranty_url = http://127.0.0.1:9/
What the settings do:
list_db = Falseswitches off the database manager, both the page at/web/database/managerand the same functions over Odoo's API, so nobody can create, copy or delete a database from outside, whatever the master password.dbfilter = ^odoo$makes Odoo serve only the database calledodoo, whatever address it is reached at.proxy_mode = Truemakes Odoo take the visitor's address, andhttps, from the headers Caddy sends. Without it, every login is logged as coming from the server itself. Only Caddy can reach Odoo's ports, so nobody else can forge those headers.workers = 4runs four processes for web requests andmax_cron_threads = 1one for scheduled jobs. Odoo's documentation suggests twice the number of CPU cores plus one, cron included, which gives five for 2 vCPU. Each worker is restarted after a request that takes it over 600 MB, and stopped at 1.6 GB, so a heavy report cannot take the whole server. With workers, the live updates in the browser, such as chat and notifications, are served by a separate process on port 8072.publisher_warranty_urlis where Odoo sends its weekly report. An address on which nothing listens stops it; step 11 has the details.- The passwords are not in this file. Odoo reads them from the secrets, as the container file below shows.
4. Define the pod
mkdir -p ~/.config/containers/systemd
cd ~/.config/containers/systemd
Create odoo.pod:
[Pod]
PodName=odoo
# Only Caddy, on this server, can reach Odoo: the ports are not open to the internet.
# 8069 serves the pages, 8072 the live updates (websocket).
PublishPort=127.0.0.1:8118:8069
PublishPort=127.0.0.1:8119:8072
# Odoo fetches its own pages when it makes PDFs: reach them through Caddy.
AddHost=odoo.example.com:host-gateway
[Install]
WantedBy=default.target
Create odoo-db.container:
[Container]
ContainerName=odoo-db
Image=docker.io/library/postgres:18
Pod=odoo.pod
Volume=odoo-db:/var/lib/postgresql
Environment=POSTGRES_USER=odoo POSTGRES_DB=postgres
Secret=odoo-db-password,type=env,target=POSTGRES_PASSWORD
HealthCmd=pg_isready -U odoo -d postgres
HealthInterval=10s
Notify=healthy
AutoUpdate=registry
[Service]
Restart=always
And odoo-app.container:
[Unit]
Requires=odoo-db.service
After=odoo-db.service
[Container]
ContainerName=odoo-app
Image=docker.io/library/odoo:20.0-20260926
Pod=odoo.pod
Volume=odoo-data:/var/lib/odoo
Volume=%h/odoo.conf:/etc/odoo/odoo.conf:ro
Secret=odoo-db-password,type=env,target=PGPASSWORD
Secret=odoo-master-password,type=env,target=ODOO_ADMIN_PASSWD
[Service]
Restart=always
Odoo 20 reads any setting from an environment variable named ODOO_ and the setting's name, so ODOO_ADMIN_PASSWD sets the master password without writing it in a file. The database password goes to PGPASSWORD, which PostgreSQL's own library reads. The odoo-data volume holds the filestore, where Odoo keeps attachments, uploaded files and generated PDFs; the records themselves are in PostgreSQL.
The Odoo image is pinned to one build, 20.0-20260926, because Odoo's database has to be upgraded after each update (see Update). PostgreSQL stays on 18 and updates itself within it.
Start it:
systemctl --user daemon-reload
systemctl --user start odoo-pod
systemctl --user enable --now podman-auto-update.timer
The first start downloads about 2.9 GB of images. When podman ps lists odoo-app, check the database manager:
curl -s http://127.0.0.1:8118/web/database/manager | grep -o 'The database manager has been disabled[^<]*'
It prints The database manager has been disabled by the administrator.
5. Create the database
The database manager is off, so create the database with Odoo's own command. Choose a password for your own login first; read keeps it out of your shell's history:
read -rs -p 'Password for you@example.com: ' pw; echo
podman exec odoo-app odoo db init odoo --country SE --username you@example.com --password "$pw"
unset pw
That creates the database odoo for a company in Sweden, with your email address as the administrator's login, instead of Odoo's default login admin with the password admin. It took about 40 seconds. Then install CRM, Sales and Invoicing:
podman exec odoo-app odoo module install -d odoo crm sale_management account
That took about two minutes. Because the company is in Sweden, Odoo also installs Sweden - Accounting (l10n_se), the Swedish localisation, and sets it up for the company: the Swedish chart of accounts, SEK as the currency, Swedish time, and VAT taxes at 25, 12, 6 and 0 percent, with separate ones for goods (G) and services (S), and for sales within the EU and outside it. More apps can be added later under Apps.
6. Put Caddy in front
Go back to root with exit, switch to machinectl shell caddy@, and add this block at the end of ~/Caddyfile:
odoo.example.com {
reverse_proxy /websocket 127.0.0.1:8119
reverse_proxy 127.0.0.1:8118
}
The live updates use the address /websocket, which goes to Odoo's separate process on port 8072; everything else goes to the workers. Without the first line, chat, notifications and other live updates in the browser stop working. Restart Caddy with systemctl --user restart caddy.
Open https://odoo.example.com, and log in with your email address and the password from step 5. Odoo's default login, admin with admin, gets "Wrong login/password".
7. Set up the company
Open Settings. Check these first:
- Companies > Update Info: the company's name, address and VAT, the VAT number, such as
SE556677889901. Save. - Companies > Configure Document Layout: your logo, and a footer for invoices. Swedish invoices must show the company's registered organisation number, and "Godkänd för F-skatt" if you have it, for example
Org.nr 556677-8899 · Godkänd för F-skattin the footer. - Permissions > Customer Account: Odoo starts with Free sign up, which lets anyone create a customer account from the login page. Choose On invitation, then Save. Customer accounts only see their own quotations and invoices, but there is no reason to let strangers create them.
- Users > Invite New Users: add your colleagues by email address. Odoo sends each one a link to choose a password, so set up email first (step 10). Every internal user can use every installed app unless you change it under Manage Users.
Then open Invoicing > Configuration > Taxes to see the Swedish VAT taxes. Products and services get a default of 25% G on sales; for a service, choose 25% S on the product. Check the taxes and the chart of accounts with your accountant before you send the first invoice.
8. From lead to invoice
The CRM starts with opportunities only. To have leads, potential customers you have not qualified yet, open CRM > Configuration > Settings, tick Leads, and Save.
- CRM > Leads > New: a name for the lead, the company and the contact's email address. Save.
- On the lead, choose Convert to Opportunity. Odoo creates a contact for the customer from the lead's company and contact name, and the opportunity appears in Pipeline, where you drag it between stages.
- On the opportunity, choose New Quotation, add products or services with Add Line, and save. Send emails the quotation as a PDF, with a link where the customer can accept it.
- Confirm turns the quotation into a sales order.
- Create Invoice, with Regular invoice, and Create Draft make an invoice from the order. Check it and Confirm.
The invoice gets a number such as INV/2026/00001 and a payment reference (OCR) for the customer. Print gives the PDF; in our test, an invoice for 10 hours at 1,000.00 kr showed VAT 25% with 2,500.00 kr and a total of 12,500.00 kr. In the accounting, the VAT went to account 2611, outgoing VAT on sales within Sweden. Pay marks the invoice as paid when the money arrives.
9. Use Odoo in Swedish
Odoo comes translated into Swedish, though some screens still show English. Open Settings > Languages > Add Languages, choose Swedish / Svenska, and Add. Each user then picks Swedish / Svenska as Language under their name at the top right > My Preferences.
PDFs follow the customer's language, so set Language to Swedish / Svenska on the customer's contact to send them Swedish invoices. In our test the invoice became "Faktura", with "Moms 25%", dates as 2026-10-14 and amounts as 12 500,00 kr.
10. Email
Odoo sends email for invitations, password resets, quotations and invoices sent to customers, and notifications. Without email set up:
- Invite under Settings > Users lists the person under Pending Invitations, but no email arrives;
- Reset Password on the login page says "Password reset instructions sent to your email address.", but nothing arrives;
- emails that wait in the queue, such as Odoo's weekly digest, fail with "Connection refused".
To send email through your email provider, store your SMTP password as a secret, as odoo:
printf %s 'the-smtp-password' | podman secret create odoo-mail-password -
Add these lines to ~/odoo.conf, with your provider's server and your own account:
; Outgoing email through your provider, with STARTTLS on port 587.
smtp_server = mail.example.com
smtp_port = 587
smtp_ssl = True
smtp_user = odoo@example.com
email_from = odoo@example.com
from_filter = odoo@example.com
And this line to odoo-app.container, under the other Secret lines:
Secret=odoo-mail-password,type=env,target=ODOO_SMTP_PASSWORD
Then restart Odoo:
systemctl --user daemon-reload
systemctl --user restart odoo-app
smtp_ssl = Truemeans STARTTLS, which port 587 uses. In this mode Odoo encrypts the connection but does not check the server's certificate.from_filtermakes Odoo send everything fromodoo@example.com, with the user's or the company's name, since most providers only let an account send from its own address.
We tested this with a test mail server on port 587 that required STARTTLS: an invitation and a password reset arrived, with links to https://odoo.example.com. We did not send through a real provider.
For port 465, which uses TLS from the start, or to have Odoo check the certificate, add the server under Settings > Technical > Email > Outgoing Mail Servers instead, with Connection Encryption set to SSL/TLS, encryption and validation or TLS (STARTTLS), encryption and validation. The Technical menu appears after Activate the developer mode at the bottom of Settings. The password is then stored in Odoo's database, and in its backups.
Odoo can also receive email. The Sales team comes with the address info: once Odoo knows your domain, every email to info@example.com that reaches Odoo becomes an opportunity in the CRM, from anyone. The journals in Invoicing have addresses of their own, sales, purchases and bank. Odoo does not receive email itself; it fetches it from a mailbox at your email provider:
- Under Settings > Emails, type your domain,
example.com, under Alias Domain and choose Create. Tick Use Custom Email Servers, and Save. - Choose Incoming Email Servers > New, and fill in a Name, the mailbox's IMAP server under Server Name, Port 993 with SSL/TLS ticked, and its Username and Password. Choose Test & Confirm.
- At your email provider, have email to
info@example.comdelivered to that mailbox, for example as an alias of it.
Odoo fetches the mailbox every five minutes, or at once with Fetch Now. If your domain already has an info@ address that people write to, change the team's address first: CRM > Configuration > Sales Teams > Sales has the Email Alias, and under Accept Emails From you can stop strangers from creating opportunities. We tested this against a test IMAP server on port 143, without TLS, with the email delivered straight to the mailbox: it became an opportunity in the Sales team at the first fetch. The mailbox password is stored in Odoo's database, and in its backups.
The Alias Domain also changes the address Odoo sends from. In our test, invitations, password resets and messages went out from notifications@example.com instead of odoo@example.com as soon as it was set, despite from_filter, and a provider that only lets your account send from its own address refuses them. To keep odoo@example.com, open Settings > Technical > Email > Alias Domains with developer mode on, choose example.com, set Default From Alias to odoo, and Save. Everything then went out from odoo@example.com again. Odoo also asks for replies to its emails to go to catchall@example.com, the Catchall Alias; we did not test receiving them.
11. Outside connections
We captured the server's DNS lookups and outgoing connections while Odoo was installed and used. Two things contacted Odoo S.A. without anyone asking:
- The weekly report. A scheduled action called Publisher: Update Notification sends a report to
services.odoo.comonce a week, over plain HTTP. It holds the database's unique ID, name and address, the number of users and active users, the installed apps, the Odoo version, and the company's name, email and phone. In our capture, it looked upservices.odoo.comand connected to it on port 80; the answer had no messages. Thepublisher_warranty_urlline from step 3 points it at an address where nothing listens, so it fails quietly. The line stays when Odoo is updated, unlike a switched-off scheduled action. - Lead enrichment. CRM installs Lead Enrichment, which once a day sends the email domains of new leads, such as
customer.example, with the database's ID toiap-services.odoo.com, to look up company details. It is paid by credit, but in our capture the domain was sent within a minute of the first lead being created, without any credits. To switch it off, open CRM > Configuration > Settings, untick Lead Enrichment, Save, and choose Uninstall.
Also switch off Partner Autocomplete, under Settings > Contacts, in the same way. It sends the company names you type in a contact to partner-autocomplete.odoo.com to suggest company details. We did not see it in the capture, because we did not type in a contact before removing it.
After these changes, we started every scheduled action by hand and rebooted the server: the capture showed no lookups or connections from Odoo. Some features use Odoo's servers when someone uses them, and only then: SMS, Peppol e-invoicing, the AI text tools in the editor, Unsplash pictures, and logins with Google or Microsoft for email.
12. Back up
As odoo:
umask 077
mkdir -p ~/backup
podman exec odoo-app odoo db dump odoo > ~/backup/odoo.zip
cp ~/odoo.conf ~/backup/
odoo db dump makes the same ZIP file as Odoo's database manager: dump.sql, the database, and the filestore folder, with every attachment. Both are needed: a database without its filestore loses every uploaded file and PDF. The dump took a few seconds and runs while Odoo keeps working. Copy ~/backup to another machine, for example with restic, and keep it private: it holds all your customers, invoices and users.
To restore into empty volumes, as odoo, with the backup in ~/backup:
systemctl --user stop odoo-pod
podman volume rm odoo-db odoo-data
systemctl --user start odoo-app
podman exec -i odoo-app sh -c 'cat > /tmp/odoo.zip' < ~/backup/odoo.zip
podman exec odoo-app odoo db load --move odoo /tmp/odoo.zip
podman exec odoo-app rm /tmp/odoo.zip
Starting odoo-app waits until PostgreSQL is ready. --move keeps the database's unique ID, as for a database that has moved; without it, Odoo treats the restore as a copy and gives it a new ID. Logins are kept, but everyone has to log in again. To try something on a copy, --neutralize instead switches off outgoing email and scheduled actions in the restored database.
13. Update
Odoo publishes a new build of each major version every week or two, with fixes, as image tags such as 20.0-20260926, which you find on the odoo image's page on Docker Hub. Each update needs a database upgrade too, so Odoo is updated by hand. Back up first, then, with the new tag in place of 20.0-20261031:
podman pull docker.io/library/odoo:20.0-20261031
sed -i 's|odoo:20.0-20260926|odoo:20.0-20261031|' ~/.config/containers/systemd/odoo-app.container
systemctl --user daemon-reload
systemctl --user restart odoo-app
podman exec odoo-app odoo module upgrade -d odoo all
systemctl --user restart odoo-app
podman image prune -a -f
The upgrade took under three minutes, and Odoo answers slowly while it runs, so update when nobody is working. curl -s -H 'Content-Type: application/json' -d '{}' http://127.0.0.1:8118/web/webclient/version_info shows the build you run. PostgreSQL is updated within 18 by podman auto-update.
A new major version is a different matter. Odoo releases one each autumn, Odoo 20 in 2026, and supports the last three, so each is fixed for about three years. Moving a database from one major version to the next changes its structure, and the image does not do it. Odoo S.A.'s upgrade service is only for Enterprise customers. For Community, the way is OpenUpgrade, run by volunteers in the Odoo Community Association (OCA), and it takes time: when we wrote this, OpenUpgrade had migration scripts for Odoo 18 to 19 for apps such as Invoicing, CRM and Sales, but not yet for the Swedish localisation, and its branch for Odoo 20 was empty. Plan to stay on a major version for most of its life, and test the move on a copy of your database, restored with --neutralize, before you trust it with your accounts.
Troubleshooting
curl to 127.0.0.1:8118 gets no answer, and Odoo's log says HTTP service running on 127.0.0.1:8069. The http_interface = 0.0.0.0 line is missing from odoo.conf. Odoo 20 then listens only inside the container.
Every login in the log comes from 203.0.113.10. proxy_mode = True is missing from odoo.conf. Restart odoo-app after adding it.
Chat and notifications do not update, and the browser's console shows a failed connection to /websocket. The reverse_proxy /websocket line is missing from the Caddyfile, or the pod does not publish port 8072.
PDFs have no layout, logo or header, and the log says wkhtmltopdf: Exit with code 1 due to network error: ConnectionRefusedError. The AddHost line in odoo.pod is missing, or names a different address. Odoo fetches its own styles from its public address when it makes a PDF.
Email to info@ does not become an opportunity. The Alias Domain under Settings > Emails must be the domain in the address, and the email must reach the mailbox Odoo fetches. Fetch Now on the incoming server fetches at once, so you need not wait five minutes.
Invitations and password resets never arrive. Email is not set up, or the password secret is missing; step 10.
Email stopped after you set the Alias Domain, and the provider refuses notifications@example.com. Odoo now sends from the alias domain's Default From Alias. Set it to odoo, as in step 10.