Setting an Odoo Docker memory limit is easy: one line in Compose. Setting the right one is harder, because Odoo has its own memory limits per worker, and the two have to agree. If the container limit is too low, the Linux kernel kills Odoo processes without warning. If it's missing, one runaway report can take the whole server down with it. This guide sizes memory and CPU limits for odoo:19 and postgres:16 in Docker Compose, starting from the number of Odoo workers and the limit_memory_* settings in odoo.conf.
It assumes a working stack like the one in the Odoo Docker Compose guide.
How Odoo uses memory and CPU in a container
With workers = 0 (the default in the official image), Odoo runs as a single multi-threaded process. That's fine for development, but it uses at most about one CPU core for Python work. Production setups enable multiprocessing by setting workers to a positive number. Odoo then runs:
- one master process that supervises the others,
workersHTTP worker processes that handle requests,max_cron_threadscron worker processes for scheduled actions (default 2),- one gevent process for websocket traffic on port 8072.
Every one of these processes counts against the container's memory limit. Odoo watches each worker separately with two settings:
- limit_memory_soft (default 2048 MiB): when a worker goes over it, Odoo recycles the worker at the end of the current request.
- limit_memory_hard (default 2560 MiB): a worker that goes over it is killed immediately.
Both values are per worker, in bytes. Docker's limit is for the whole container. That is the core of the sizing problem: with the defaults and 4 workers plus 2 cron workers, Odoo would allow up to 6 x 2 GiB = 12 GiB before it starts recycling anything. A 4 GB container limit would be hit long before Odoo's own safety net kicks in.
Step 1: decide the number of Odoo workers
Odoo's deployment documentation gives two rules of thumb:
- Workers: at most
(number of CPUs x 2) + 1, with cron workers counted against the same CPUs. - Load: one worker handles roughly 6 concurrent users.
For memory, the same documentation assumes about 20% heavy requests at around 1 GB per worker and 80% light requests at around 150 MB, which averages out to roughly 325 MB per worker. Its sample server (4 CPUs, 60 concurrent users) runs 8 workers plus 1 cron worker and needs about 3 GB of RAM.
For a small company with 15 to 25 people using Odoo at once, 4 workers and 1 cron worker on 2 CPUs is a sensible starting point. We'll size the container for that. For the full list of server options, see Odoo server configuration.
Step 2: set the limit_memory values in odoo.conf
Mount your own config directory at /etc/odoo. Start from the official template, which already sets addons_path and data_dir for the image:
[options]
addons_path = /mnt/extra-addons
data_dir = /var/lib/odoo
proxy_mode = True
workers = 4
max_cron_threads = 1
; 640 MiB: recycle a worker after the request that crossed this
limit_memory_soft = 671088640
; 1 GiB: kill a worker immediately above this
limit_memory_hard = 1073741824
limit_time_cpu = 600
limit_time_real = 1200
limit_request = 8192A few notes on these numbers:
- The soft limit catches the normal case: a worker that grew after a big import or report gets replaced with a fresh one after it answers.
- The hard limit is the emergency stop for a single request that keeps growing. Keep it well above the soft limit so normal heavy reports finish.
- Odoo's documentation describes both limits in terms of virtual memory per worker. Virtual memory is usually larger than the resident memory the kernel charges to the container, so these values are conservative. Measure, then adjust (step 5).
limit_time_cpuandlimit_time_realare raised from the defaults (60 and 120 seconds) to the values in Odoo's own sample so long reports and imports aren't cut off. Lower them if you prefer to fail fast.
With workers enabled, websocket traffic goes to the gevent process on port 8072, so the reverse proxy in front of Odoo must route /websocket there. That's also why proxy_mode = True is set.
Step 3: set the Docker Compose memory and CPU limits
Now add the matching limits to compose.yaml with deploy.resources. docker compose up applies them on a single host; you don't need Swarm.
services:
db:
image: postgres:16
environment:
POSTGRES_DB: postgres
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
volumes:
- db-data:/var/lib/postgresql/data
shm_size: 256mb
deploy:
resources:
limits:
cpus: "1.5"
memory: 2g
reservations:
memory: 1g
healthcheck:
test: ["CMD-SHELL", "pg_isready -U odoo -d postgres"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
odoo:
image: odoo:19
depends_on:
db:
condition: service_healthy
restart: true
ports:
- "8069:8069"
- "8072:8072"
environment:
HOST: db
USER: odoo
PASSWORD: odoo
volumes:
- odoo-data:/var/lib/odoo
- ./config:/etc/odoo
- ./addons:/mnt/extra-addons
deploy:
resources:
limits:
cpus: "2.0"
memory: 4g
pids: 512
reservations:
memory: 2g
restart: unless-stopped
volumes:
db-data:
odoo-data:How the Odoo numbers fit together:
| Process | Count | Budget each | Total |
|---|---|---|---|
| HTTP workers | 4 | 640 MiB (soft limit) | 2.5 GiB |
| Cron worker | 1 | 640 MiB | 640 MiB |
| Master + gevent | 2 | about 200 MiB | about 400 MiB |
| Expected peak | 7 | about 3.5 GiB |
The 4 GB limit leaves headroom for one worker briefly running above its soft limit. The general rule: container memory limit >= (workers + max_cron_threads + 2) x limit_memory_soft, plus a margin. If you add workers, raise the container limit in the same commit.
The fields used:
limits.memory: the hard cap. When the container reaches it, the kernel's OOM killer kills a process inside it. If it picks the Odoo master process, the whole container stops andrestart: unless-stoppedbrings it back, dropping every request in flight.limits.cpus: CPU time the container may use, as a number of cores."2.0"matches 4 workers by the rule of thumb above.limits.pids: caps the number of processes, a cheap guard against a fork loop.reservations.memory: a soft guarantee. On a single host Docker uses it as a soft limit when the host runs short of memory; it does not reserve RAM up front.
Compose also accepts the older service-level attributes mem_limit, mem_reservation, cpus and pids_limit. They do the same job. Pick one style per file; if you set both, the values must match.
Step 4: limits for PostgreSQL
PostgreSQL needs its own budget. Two things to watch:
- shared memory: Docker gives containers a 64 MB
/dev/shmby default. PostgreSQL uses it for parallel queries, and a small one causes "could not resize shared memory segment" errors on large reports.shm_size: 256mbavoids that. - shared_buffers: PostgreSQL's main cache defaults to 128 MB. If you raise it (a common starting point is about a quarter of the container's memory), keep it well under
limits.memory, since each connection also uses memory on top of it. Odoo opens up todb_maxconnconnections per process (default 64).
On a small server, give PostgreSQL around a third of the memory you give Odoo, and never put a limit on the database that is tighter than its normal working set: an OOM kill of PostgreSQL forces crash recovery on the next start.
Step 5: measure and adjust
Start the stack and watch real usage while people work:
docker compose up -d
docker stats
docker stats shows each container's memory usage against its limit and its CPU percentage. Check it during your heaviest tasks: month-end reports, large imports, website traffic peaks. Then:
- Memory sits near the limit: raise
limits.memoryor lower the number of workers. - Odoo logs show workers recycled for memory often: raise
limit_memory_softand the container limit together. Odoo logs a line each time it recycles a worker for memory; search for it withdocker compose logs odoo | grep -i "memory limit". - CPU pinned at the limit and slow pages: raise
limits.cpusbefore adding workers. More workers than CPU only adds queueing. - Container restarts with exit code 137: the kernel killed it for memory. Check
docker inspect --format '{{.State.OOMKilled}}' $(docker compose ps -q odoo).
Container scheduling on a single host
On one machine, Docker Compose doesn't schedule containers across servers; it decides how they share the host. The controls for that:
cpu_shares: relative CPU weight when the host is busy. Give Odoo a higher weight than background jobs so users stay responsive.cpuset: pins a container to specific cores, for example"0-1"for Odoo and"2-3"for PostgreSQL. Rarely needed, but useful on a shared host.oom_score_adj: makes a container more or less likely to be chosen by the OOM killer when the host itself runs out of memory. A lower value fordbprotects the database first.
Placement rules such as deploy.placement.constraints choose which node runs a container. They only mean something in a Docker Swarm cluster; see Odoo server placement in Swarm.
More workers or more containers?
docker compose up -d --scale odoo=2 starts a second Odoo container. For Odoo on one host it's rarely the better option. Both containers must share the same filestore volume, the fixed "8069:8069" port mapping has to go (two containers can't bind the same host port), and you need a load balancer in front. Raising workers inside one container gets the same extra capacity with none of that. Scale out across containers or servers once one machine is no longer enough; see how to scale Odoo.
Sizing without the guesswork
On Easy Instance, workers are the unit you size and pay for: $30 per Odoo worker per month, with no per-user fees (pricing). Every instance shows live and historical CPU, memory and worker metrics, and you can add workers from the dashboard without downtime. You can try it free for 10 days with no credit card.