You can run Odoo on your own hardware with Docker and get Odoo Online performance or better, but only if you size it with Odoo's worker formula. A default container in threaded mode will not do. For most small businesses, a server with 4 CPU cores and 8 GB of RAM covers a few dozen users. Odoo and PostgreSQL then share the machine, and multi-process mode runs with 9 workers or fewer. The harder question is not hardware. It is whether your Odoo Online database depends on Enterprise modules, because that decides which edition you can legally and practically migrate to.
TL;DR
- Solo founder or micro team (Ana, 3 users, invoicing and CRM): run Odoo Community in Docker on a 2 core, 4 GB machine with 2 to 3 workers, because at this size PostgreSQL defaults are almost enough and RAM is the only real constraint.
- Growing SME (Marc's 25-person distribution company with inventory and accounting): plan for 4 to 8 cores, 8 to 16 GB of RAM and tuned PostgreSQL, because stock moves and accounting reports are the heavy requests that exhaust workers first.
- Business that relies on Enterprise features (Studio, full accounting, helpdesk): self-host Odoo Enterprise with your existing subscription, because dropping to Community means losing modules and running a migration that Odoo does not officially support.
- Company with custom modules blocked on Odoo Online: self-host to install them, because unrestricted module installation is the strongest functional reason to leave Odoo Online.
- Homelab tinkerer testing Odoo before committing: a single container pair on existing hardware is fine, because you can measure your real workload before buying anything.
- Team with no one to own the server: stay on Odoo Online or pick a managed host, because backups, upgrades and PostgreSQL tuning become your job the day you leave.
The central tradeoff: you swap a per-user subscription for full control over performance and modules, and you pay for that control in hardware sizing, tuning and upkeep.
Table of contents
- What do you actually keep and lose when you leave Odoo Online?
- Community or Enterprise: which edition can your database move to?
- How much CPU and RAM does Odoo need for 5, 25 or 100 users?
- How many Odoo workers should you configure?
- Setting limit_memory, limit_time and the other worker limits safely
- A Docker Compose layout for Odoo, PostgreSQL, the gevent port and a reverse proxy
- Which PostgreSQL settings matter most for Odoo performance?
- How fast will your database and filestore grow on disk?
- Where should you run it: home server, VPS, NAS or managed personal cloud?
- Reverse proxy, websockets and static asset caching
- What does it really cost over 3 years compared with Odoo Online?
- What slows down first when Odoo gets busy, and how do you find it?
- Which Odoo hosting setup fits your business?
What do you actually keep and lose when you leave Odoo Online?
Leaving Odoo Online is not a data loss event. Your records, attachments and configuration travel with you. What changes is who runs the machine underneath them, and which limits apply to you.
Odoo lets you download a backup of your Online database. The archive follows the standard Odoo backup format: a dump.sql PostgreSQL dump, a filestore folder with your attachments, and a manifest.json that records the version and installed modules. That archive restores onto a self-hosted instance running the same major version. That last condition matters more than anything else in this section.
| Area | On Odoo Online | Self-hosted with Docker |
|---|---|---|
| Custom modules | Only standard apps and Studio, no custom Python code | Any module from your own code, the community or third parties |
| Database access | No direct PostgreSQL access | Full psql access, SQL reports, replicas |
| Performance tuning | Fixed by Odoo, no worker or memory settings | You set workers, memory limits and PostgreSQL parameters |
| Backups | Handled by Odoo | Your responsibility, including off-site copies |
| Version upgrades | Scheduled and run by Odoo | You run them, with the upgrade service or migration scripts |
| Outgoing mail and DNS | Preconfigured | You configure SMTP, SPF and DKIM yourself |
The first three rows are why people leave. The last three are what they underestimate. On Odoo Online you never chose a worker count or a shared_buffers value, so you never saw how much load your database really generates. On your own server, every one of those numbers becomes a decision, and the rest of this article walks through them in order.
Community or Enterprise: which edition can your database move to?
Every Odoo Online database runs the Enterprise edition. That single fact shapes your whole migration, because the backup you download expects Enterprise modules to be present on the server that restores it.
Self-hosted Enterprise is the direct path: Odoo Enterprise can run on-premise, and the source is available to subscribers from odoo.com. Your Online backup restores onto it without module surgery. You keep paying the per-user subscription, but you gain control over hardware, workers and custom code.
Community is free, but it is a different product: Odoo Community is licensed under LGPLv3 and runs in the official odoo Docker image at no cost. It lacks Enterprise modules such as web_studio, account_accountant, helpdesk, sign and documents. If your database uses any of them, those models and their data have nowhere to go.
Downgrading is not officially supported: moving an Enterprise database to Community means uninstalling Enterprise modules first, then cleaning out leftover views and references. Odoo does not provide a tool for this. Expect a test restore, a list of broken views and some manual SQL before it boots cleanly.
Watch for intermediate SaaS versions: Odoo Online often runs releases labelled like saas~17.2, which do not exist as on-premise builds. A database on one of those cannot restore onto a self-hosted 17.0. You either wait for the next major version or ask Odoo to upgrade the database before you export.
Check your installed modules before anything else: the manifest.json inside the backup lists every module. Searching it for Enterprise module names takes five minutes and tells you which path is realistic.
Pick the edition first. Hardware sizing comes after, because Enterprise modules such as Studio add their own load.
How much CPU and RAM does Odoo need for 5, 25 or 100 users?
Odoo's own deployment documentation gives you the two rules that drive every sizing decision. One worker serves roughly 6 concurrent users. The theoretical maximum worker count is (CPU cores × 2) + 1. For memory, the documentation assumes 80% of requests are light, at around 150 MB each, and 20% are heavy, at around 1 GB. That averages out to about 325 MB per worker.
Concurrent users are not named users. A 25-seat subscription where half the team logs in twice a day generates far less load than 25 warehouse staff scanning barcodes all shift. The table below applies the formulas to the worst case, where every user is active at once, and adds headroom for PostgreSQL and the operating system on the same machine.
| Concurrent users | Workers needed (users ÷ 6, rounded up, plus margin) | Starting server (Odoo and PostgreSQL together) |
|---|---|---|
| 5 | 2 to 3, plus 1 cron worker | 2 cores, 4 GB RAM |
| 25 | 5, plus 1 cron worker | 4 cores, 8 GB RAM |
| 50 | 9, plus 1 cron worker | 6 cores, 12 to 16 GB RAM |
| 100 | 17, plus 1 to 2 cron workers | 8 or more cores, 16 GB RAM or more, PostgreSQL on its own host |
These are starting points derived from documented rules, not benchmarks. Two things push you up a row faster than user count does. Heavy modules: accounting reports, MRP scheduling and large stock valuations turn more of your requests into 1 GB requests. Storage speed: PostgreSQL on spinning disks or a slow network volume will bottleneck long before CPU does. Put the database on SSD or NVMe whatever tier you pick.
At 100 concurrent users, splitting Odoo and PostgreSQL onto separate machines stops being optional. Their memory demands start competing with each other.
How many Odoo workers should you configure?
The official odoo Docker image starts with workers = 0. That is threaded mode: a single process that handles every request with Python threads and shares one interpreter lock. It is fine for a test instance. In production it is the most common reason a self-hosted Odoo feels slower than Odoo Online. Set the value in /etc/odoo/odoo.conf, which the image reads at startup.
Pick the smaller of two numbers: take the users-based count from the previous section and the CPU ceiling of (cores × 2) + 1, then use whichever is lower. On a 4 core server with 60 concurrent users, the documentation's own example arrives at 8 HTTP workers. Demand alone would ask for 10.
Reserve cron capacity separately: max_cron_threads defaults to 2, and those cron workers run scheduled actions such as mail queues, recurring invoices and stock rules. One is usually enough below 50 users. Count them in your RAM budget, because a heavy scheduled job uses memory like any heavy request.
Expect an extra gevent process: once workers is above 0, Odoo also starts a gevent process for websockets on gevent_port, 8072 by default since version 16. Earlier releases called it longpolling_port. Live chat, notifications and the Discuss app depend on it.
Size database connections with workers: every worker opens its own PostgreSQL connections, up to db_maxconn, which defaults to 64. With 9 workers and 2 crons at that default, Odoo could in theory ask for far more connections than PostgreSQL's default max_connections of 100. Lower db_maxconn or raise the PostgreSQL limit deliberately.
Do not oversubscribe the CPU: going past the ceiling does not add throughput. It adds context switching and memory pressure, and both show up as slow pages under load.
Setting limit_memory, limit_time and the other worker limits safely
Worker limits are Odoo's guard rails. Each one recycles or kills a worker that misbehaves, so one runaway report cannot take the whole server down. Set them too loose and a single export can push the host into swap. Set them too tight and your users see failed imports and blank report screens. All of them live in odoo.conf, and the memory values are written in bytes.
limit_memory_soft: once a worker's memory passes this threshold, Odoo finishes the current request and then recycles the worker. Recent versions default to 2048 MiB. Multiply your soft limit by your worker count and compare the result with the RAM you set aside for Odoo. If the product is larger than the RAM, the limit protects nothing.
limit_memory_hard: a worker that crosses this ceiling is killed immediately, mid-request. The default is 2560 MiB. Keep a clear gap above the soft limit so normal heavy requests finish and get recycled instead of being cut off.
limit_time_cpu: the CPU seconds a single request may consume. The default is 60. Large accounting reports and stock valuations can exceed it on modest hardware, and the symptom is an error a minute into the report.
limit_time_real: wall clock time per request, 120 seconds by default. It catches requests stuck waiting on locks or slow disks. Raise it in step with limit_time_cpu, never below it.
limit_time_real_cron: a separate wall clock limit for scheduled jobs. Leave it at -1 to inherit limit_time_real, or set it higher for nightly jobs you know run long.
limit_request: the number of requests a worker serves before it is recycled. It is a slow leak guard, and the default rarely needs changing.
Raise limits for a reason you can name, such as a specific report. Do not raise them to silence errors.
A Docker Compose layout for Odoo, PostgreSQL, the gevent port and a reverse proxy
A production Odoo stack in Docker needs three services and three volumes. Keep it that small. Every extra moving part is one more thing to back up and upgrade.
The db service: use the official postgres image, pinned to a major version such as postgres:16, never latest. Set POSTGRES_USER=odoo and POSTGRES_DB=postgres, then mount a named volume on /var/lib/postgresql/data. Publish no port to the host. Only the Odoo container should reach it, over the internal Compose network.
The odoo service: pin the image to your database's major version, for example odoo:17.0, because a restored database refuses to start on a mismatched release. The HOST, USER and PASSWORD environment variables point it at the db service. Mount /var/lib/odoo for the filestore and sessions, /etc/odoo for odoo.conf, and /mnt/extra-addons for custom modules.
Two internal ports, not one: port 8069 serves normal HTTP requests and port 8072 serves websockets once workers are enabled. Neither needs publishing to the internet. The reverse proxy reaches both over the Compose network.
The proxy service: Caddy, Traefik or Nginx terminates TLS and routes /websocket to 8072 and everything else to 8069. Set proxy_mode = True in odoo.conf so Odoo trusts the forwarded headers and generates correct HTTPS URLs.
Lock down the database manager: set list_db = False and a strong admin_passwd. The /web/database/manager page can drop or download databases, and on a public hostname it is the first thing scanners probe.
Resource limits per container: give mem_limit values to both odoo and db so a runaway worker cannot starve PostgreSQL. Match them to the RAM budget from your sizing.
Which PostgreSQL settings matter most for Odoo performance?
The official postgres image ships with defaults built to start on almost any machine, not to run an ERP. Odoo issues a large number of small queries per page, plus occasional heavy aggregations for reports. Most of the gain comes from five parameters. You can set them in Compose with command: postgres -c shared_buffers=2GB -c ... or in a mounted postgresql.conf.
| Setting | PostgreSQL default | Starting point for Odoo |
|---|---|---|
shared_buffers | 128 MB | About 25% of the RAM you give PostgreSQL |
effective_cache_size | 4 GB | 50 to 75% of that RAM, so the planner knows the OS cache exists |
work_mem | 4 MB | 16 to 64 MB for report-heavy workloads, kept modest because each sort can use it |
random_page_cost | 4.0 | 1.1 on SSD or NVMe, which makes the planner choose index scans more often |
max_connections | 100 | Above your total Odoo workers × db_maxconn, or lower db_maxconn instead |
maintenance_work_mem | 64 MB | 256 MB or more, to speed up vacuum and index builds during upgrades |
Two Docker-specific traps sit outside this table. Docker gives containers 64 MB of /dev/shm by default, and PostgreSQL parallel queries can fail with shared memory errors once that fills up. Set shm_size: 256mb or higher on the db service. The second trap is autovacuum. Leave it on. Tables such as mail_message, bus_bus and ir_attachment take constant inserts and deletes, and a database that stops vacuuming slows down week by week.
After tuning, turn on log_min_duration_statement at a threshold like 500 ms for a few days. The slow query log shows you whether the bottleneck is PostgreSQL or Odoo's Python code. Settings alone cannot tell you that.
How fast will your database and filestore grow on disk?
No universal growth rate exists for Odoo. A 10-person consultancy and a 10-person e-commerce shop can differ by orders of magnitude. The good news is that your Odoo Online backup already contains your real baseline. Unzip it, check the size of dump.sql and the filestore folder, and compare against a backup from a few months earlier if you have one. That gives you a measured slope instead of a guess.
The filestore usually outgrows the database: Odoo stores attachments as files under /var/lib/odoo/filestore/<database_name>, named by their SHA-1 checksum, so identical files are stored once. Check its size with du -sh on that path.
Product images multiply quietly: each product image is kept at several resolutions, from image_1920 down to image_128. A catalogue with thousands of photographed products grows in steps whenever someone bulk-imports images.
Generated PDFs are kept: posted invoices and other reports are often saved as attachments, so every document you send to a customer also lands in the filestore.
Email is the database's biggest grower: mail_message and mail_tracking_value record every chatter message and every tracked field change. Inbound email with attachments feeds both the database and the filestore at the same time.
Measure the database from inside: SELECT pg_size_pretty(pg_database_size('yourdb')); in psql gives the live size, and the same query run monthly gives you the trend.
Deleted files linger briefly: Odoo removes unreferenced filestore files through a scheduled garbage collection, not at deletion time. Disk usage drops later than you expect.
Then multiply. Your backup retention, times the combined size, is the off-site storage you actually need. That number is often larger than the server disk itself.
Where should you run it: home server, VPS, NAS or managed personal cloud?
Odoo is an ERP, so the deciding questions are not the usual homelab ones. Can your accountant reach it from a client site? Does disk latency hold up during month-end reporting? Who notices when it goes down at 8 a.m.? The sizing tiers from earlier apply to every option below. What changes is where the bottleneck and the operational burden sit.
| Option | Where it fits | What to watch |
|---|---|---|
| Home or office server | Small teams that mostly work on one site, with existing hardware that has SSD storage and 8 GB of RAM or more | Residential upload speed, dynamic IPs or CGNAT, port 443 forwarding, and power or internet outages that become ERP outages |
| VPS | Remote teams that need a public endpoint and predictable network uptime | Shared vCPUs that slow down under neighbour load, and block storage that may be slower than local NVMe for PostgreSQL |
| NAS with Docker | Very small teams that want everything in one box | Many entry-level NAS units ship with 2 to 4 GB of RAM and low-power CPUs, which caps you at the smallest tier |
| Managed personal cloud such as Yundera | Teams that want a dedicated server without assembling the stack by hand | Fewer low-level knobs than a raw VPS, so check that you can edit odoo.conf and PostgreSQL settings |
Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. Apps install from an app store, and each one gets a public HTTPS subdomain through NSL.SH mesh routing, without a static IP, port forwarding or manual TLS setup.
Whatever you pick, test with a restored copy of your real database first. Generate one month-end report and one large stock valuation, then read the worker and PostgreSQL logs before you move users over.
Reverse proxy, websockets and static asset caching
The proxy in front of Odoo does more than terminate TLS. Get these details wrong and users see dropped notifications, failed uploads or reports that time out even though Odoo finished them.
Forward the websocket upgrade: the /websocket route needs the Upgrade and Connection headers passed through. Caddy and Traefik do this automatically. Nginx needs explicit proxy_set_header Upgrade $http_upgrade and Connection "upgrade" lines. Without them, Discuss and live notifications fall back to silence, with no visible error.
Match proxy timeouts to Odoo's limits: Nginx's default proxy_read_timeout is 60 seconds, which is half of Odoo's 120 second limit_time_real. A slow report that Odoo would finish gets cut off at the proxy first. Odoo's own Nginx example uses 720 seconds. Whatever you choose, keep it at or above your Odoo limit.
Raise the upload size: Nginx rejects request bodies over 1 MB by default with client_max_body_size. Importing a CSV of products or attaching a scanned contract fails with a 413 error until you raise it.
Pass the real client headers: send X-Forwarded-Host, X-Forwarded-For and X-Forwarded-Proto. Combined with proxy_mode, they give Odoo correct IP addresses in logs and correct HTTPS links in outgoing emails.
Cache static assets at the proxy: files under /web/static/ and the compiled bundles under /web/assets/ change only when you update modules. Cache them with long expiry headers and enable gzip or zstd compression. Workers then stop spending time on JavaScript and CSS.
Offload attachment downloads: recent Odoo versions support x_sendfile = True, which lets Nginx serve filestore files directly. This frees a worker that would otherwise stream a large PDF byte by byte.
What does it really cost over 3 years compared with Odoo Online?
Any honest comparison starts with the licence, not the server. Odoo's pricing page ties on-premise and Odoo.sh hosting of Enterprise to its higher plan, not to the entry plan many Odoo Online customers start on. If you need Enterprise modules, self-hosting can increase your per-user licence bill. The savings case only holds clearly for Community, or for teams that are already on the higher plan. Check the current figures for your seat count before anything else.
| Cost line over 36 months | Odoo Online | Self-hosted |
|---|---|---|
| Licence | Per user per month, hosting included | Zero for Community; per user per month for Enterprise, on the higher plan |
| Infrastructure | Included | Server or VPS rental, or hardware plus electricity, sized from the tiers earlier |
| Backup storage | Included | Off-site storage priced by size × retention, growing with the filestore |
| Major upgrades | Run by Odoo | Your time, plus the upgrade service or migration scripts. Odoo supports only the last 3 major versions, and a new one ships about once a year |
| Monitoring and patching | Included | Hours per month for image updates, PostgreSQL maintenance and log review |
| Custom module freedom | Not available | Included, which can replace paid workarounds or manual processes |
The line most people leave out is labour. Price your own hours, or a contractor's, for one major upgrade and twelve months of routine maintenance, then multiply across 3 years. A 5-user Community install on spare hardware almost always comes out cheaper. A 50-user Enterprise install with heavy customisation can go either way, and the decision usually turns on the value of custom modules rather than on the server bill.
What slows down first when Odoo gets busy, and how do you find it?
Slowness in a self-hosted Odoo almost always has a single first cause, and the fix depends on which one it is. Adding hardware before you know the cause usually moves the problem rather than removing it. Work through these in order.
Read the request timing columns first: every HTTP line in the Odoo log ends with three numbers: the SQL query count, the time spent in SQL, and the time spent elsewhere. A page with 2,000 queries points to a module issue. A page with few queries but high SQL time points to PostgreSQL. High non-SQL time points to Python code or a starved worker.
Look for worker recycling messages: log lines saying a worker's virtual memory limit or CPU time limit was reached mean requests are being cut short. If they appear under normal daily use, you have too few workers or limits set below your real workload.
Check for queueing, not just load: when every worker is busy, new requests wait. Users feel this as a delay before the page even starts loading. docker stats showing every Odoo process near its CPU share, with spare RAM, means you need more workers or cores.
Watch for swap: free -m or vmstat 1 reporting swap activity means your worker count times memory limits exceeds real RAM. Swapping PostgreSQL slows everything at once.
Inspect locks during slow moments: SELECT pid, wait_event_type, query FROM pg_stat_activity; shows sessions waiting on locks. Long-running crons that touch stock or accounting often block interactive users.
Profile one specific screen: since Odoo 15, debug mode includes a built-in profiler. It records the Python and SQL breakdown for one slow action. Use it when a single view is slow and everything else is fine.
Which Odoo hosting setup fits your business?
Match your situation to the row below. The worker counts and server tiers come straight from the sizing rules earlier in the article.
| Profile | Recommendation | Main reason |
|---|---|---|
| Solo founder, 1 to 3 users | Community in Docker on existing hardware or a NAS, 2 to 3 workers | Load is tiny, and RAM is the only constraint |
| Small office, 5 to 15 users, one site | Community or Enterprise on an office server, 4 cores and 8 GB | Local latency is low, and on-site staff can watch it |
| Remote team, 10 to 30 users | VPS, a dedicated server or a managed personal cloud such as Yundera | A public HTTPS endpoint matters more than raw hardware |
| SME on Enterprise, 25 to 50 users | Self-hosted Enterprise, 6 cores, 12 to 16 GB, tuned PostgreSQL | Keeps Studio and full accounting while you gain control |
| 100 or more concurrent users | Separate Odoo and PostgreSQL hosts | Their memory demands compete on one machine |
| Heavy custom modules | Any self-hosted option with full odoo.conf access | Odoo Online cannot run custom Python code |
| No technical owner | Stay on Odoo Online | Upgrades and backups need a named person |
Next steps
Staying small (first two rows):
- Restore your Online backup into a test container.
- Set
workers,list_db = Falseandproxy_mode. - Schedule an off-site backup.
Going remote or growing (rows three to five):
- Size from your real concurrent users, not your seat count.
- Apply the PostgreSQL starting values and enable the slow query log.
- Load test with a month-end report before moving users.
Custom or no owner (last two rows):
- List the modules in
manifest.json. - Price one major upgrade in hours.
- Decide who owns the server before you leave Odoo Online.
