Self-hosting Odoo
Odoo with Docker Compose in production
A working Odoo 18 Docker Compose setup for production: postgres:16, volumes, odoo.conf, healthchecks, nginx, module upgrades, backups and common pitfalls.
CICDoo Engineering Updated 8 min read
The short answer
A production Odoo Docker Compose stack runs the official odoo:18 image and postgres:16 on a private network, with named volumes for the database and filestore, your custom addons and odoo.conf mounted read-only, and nginx in front for TLS and /websocket. Set workers in odoo.conf, pin image versions, and run module upgrades with a one-off container using -u and --stop-after-init.
On this page
The short version
The official odoo image on Docker Hub is maintained by Odoo and is fine for production, as long as you treat the defaults as a demo and replace them. A production stack needs five things the quick-start examples skip: a real odoo.conf with workers enabled, persistent named volumes for both PostgreSQL and the filestore, a private network with no database port published, healthchecks, and a reverse proxy that handles TLS and the /websocket route.
Below is a complete, working layout, followed by how to run upgrades, back up the volumes, and avoid the mistakes that cause most support tickets.
Directory layout
odoo-prod/
docker-compose.yml
.env
config/
odoo.conf
addons/ # your custom and third-party modules
nginx/
odoo.conf # nginx site config
Keep this directory in Git (except .env), so the whole stack is reproducible on a new server.
The docker-compose.yml
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_DB: postgres
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d postgres"]
interval: 10s
timeout: 5s
retries: 5
shm_size: 256mb
odoo:
image: odoo:18.0
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
HOST: db
PORT: 5432
USER: ${POSTGRES_USER}
PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- odoo-data:/var/lib/odoo
- ./config/odoo.conf:/etc/odoo/odoo.conf:ro
- ./addons:/mnt/extra-addons:ro
networks:
- backend
- frontend
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8069/web/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
nginx:
image: nginx:1.27
restart: unless-stopped
depends_on:
- odoo
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/odoo.conf:/etc/nginx/conf.d/default.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
networks:
- frontend
volumes:
db-data:
odoo-data:
networks:
backend:
internal: true
frontend:
And the .env file next to it (never commit it):
POSTGRES_USER=odoo
POSTGRES_PASSWORD=replace-with-a-long-random-string
Notes on the choices:
- Image tags:
odoo:18.0tracks the latest Odoo 18 build, which Odoo republishes regularly with fixes. For strict reproducibility, pin the image digest (odoo:18.0@sha256:...) and bump it on purpose after testing on staging. - The
backendnetwork isinternal, so PostgreSQL has no route to the internet and no published port. Only Odoo can reach it. Odoo also joinsfrontendso nginx can reach it. - HOST, PORT, USER, PASSWORD are the environment variables the official image's entrypoint turns into
--db_host,--db_port,--db_userand--db_password. You can put these inodoo.confinstead, but not both with different values. /web/healthexists in Odoo 16 and later and returns a small JSON payload without touching a database. The official image includes curl.shm_sizeavoids "could not resize shared memory segment" errors from PostgreSQL parallel queries in containers, where the default/dev/shmis 64 MB.
The odoo.conf inside the container
The image reads /etc/odoo/odoo.conf. Mount your own:
[options]
addons_path = /mnt/extra-addons
data_dir = /var/lib/odoo
admin_passwd = a-long-random-master-password
list_db = False
dbfilter = ^erp$
proxy_mode = True
workers = 5
max_cron_threads = 1
gevent_port = 8072
db_maxconn = 16
limit_memory_soft = 2147483648
limit_memory_hard = 2684354560
limit_time_cpu = 600
limit_time_real = 1200
The core addons path is added automatically by Odoo, so addons_path only needs your extra directories. If your addons directory contains repositories rather than modules (for example addons/oca-web/web_responsive), list each repository directory separately, because Odoo only looks one level deep:
addons_path = /mnt/extra-addons/custom,/mnt/extra-addons/oca-web,/mnt/extra-addons/enterprise
For Enterprise, clone the Enterprise addons (available to subscribers and partners) into addons/enterprise and put that path first. The Community vs Enterprise guide explains the licensing side.
nginx in front
nginx proxies normal HTTP traffic to port 8069 and the websocket to 8072. Inside the Compose network, the upstream host is the service name odoo.
upstream odoo { server odoo:8069; }
upstream odoochat { server odoo:8072; }
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name erp.example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl;
http2 on;
server_name erp.example.com;
ssl_certificate /etc/letsencrypt/live/erp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/erp.example.com/privkey.pem;
client_max_body_size 200m;
proxy_read_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;
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_pass http://odoo;
proxy_redirect off;
}
}
For certificates, the simplest approach is certbot on the host with the webroot method (mount /var/www/certbot into nginx as well) and a deploy hook that runs docker compose exec nginx nginx -s reload after renewal. Traefik or Caddy with automatic ACME are good alternatives if you prefer labels over config files.
First start and creating the database
docker compose up -d
docker compose logs -f odoo
With list_db = False the database manager is hidden, so create the database from the command line with a one-off container:
docker compose run --rm odoo odoo -d erp -i base --without-demo=all --stop-after-init
The odoo after the service name is the command; the image entrypoint appends the database connection flags for you. --stop-after-init makes the container exit when initialisation is done instead of starting a second server.
Upgrading modules with -u
When you deploy new code for a custom module, Odoo must update the module's data and schema:
git -C addons/custom pull
docker compose run --rm odoo odoo -d erp -u my_module --stop-after-init
docker compose restart odoo
To update after a new odoo:18.0 image (bug fixes in core modules):
docker compose pull odoo
docker compose run --rm odoo odoo -d erp -u all --stop-after-init
docker compose up -d odoo
-u all on a large database can take many minutes. Take a backup first and do it in a maintenance window. Moving between major versions (17 to 18) is a different operation that needs a database migration, see upgrading Odoo 17 to 18.
Backing up the volumes
Do not back up a running PostgreSQL by copying db-data. Use pg_dump through the container, and archive the filestore from the Odoo volume:
#!/usr/bin/env bash
set -euo pipefail
cd /srv/odoo-prod
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p backups
docker compose exec -T db pg_dump -U odoo -Fc erp > "backups/erp-$STAMP.dump"
docker compose run --rm --no-deps --user root -v "$PWD/backups:/backup" --entrypoint tar odoo \
-czf "/backup/erp-filestore-$STAMP.tar.gz" -C /var/lib/odoo/filestore erp
Then ship both files off the server. The Odoo backups to S3 guide has a complete script with encryption, retention and restore steps.
Resource limits, logging and restarts
Containers make it easy to forget that Odoo's own limits and Docker's limits are separate layers. Configure both, and make them agree.
odoo:
# ...as above
cpus: 4
mem_limit: 12g
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
- Memory. Docker's
mem_limitis a hard ceiling for the whole container: every worker, the cron processes and the gevent process together. If the kernel OOM killer fires, the entire container dies, not one worker. Keepworkers x limit_memory_softcomfortably belowmem_limit, so Odoo recycles its own workers before Docker kills everything. - CPU. With
cpus: 4, sizeworkersfor four cores even if the host has sixteen. Too many workers on too few cores increases latency for everyone. - Logs. The official image logs to stdout, which is what you want in a container. Without the
max-sizerotation above, the default json-file driver grows without limit and eventually fills the disk. Read logs withdocker compose logs --since 1h odoo, or ship them to Loki or a similar system. - Restarts.
restart: unless-stoppedbrings the stack back after a reboot or crash. Combined with the healthcheck,docker compose psshows at a glance whether Odoo is actually answering, not just running. - Time zone. Leave the containers on UTC. Odoo converts dates per user, and a non-UTC container clock only confuses cron schedules and logs.
Common pitfalls
Permission denied on the filestore or addons. The image runs Odoo as the odoo user, which is uid 101 in current images (check with docker compose exec odoo id). Bind-mounted host directories must be readable by that uid, and anything Odoo writes to (the data directory) must be writable. Named volumes avoid most of this; for bind mounts, sudo chown -R 101:101 ./data fixes it.
Module not found after mounting addons. Either the path in addons_path points at a directory of repositories instead of a directory of modules, or you forgot to click "Update Apps List" (or run -u base) after adding new modules. Also check that each module has a __manifest__.py.
workers = 0 in a container. The image's default config does not enable multi-process mode. Without workers, one threaded process handles everything, the memory and time limits do not apply, and the websocket is served on 8069 instead of the gevent port. Set workers explicitly. Size it to the CPU and memory the container can actually use, not the host's total, especially if you set Compose cpus or mem_limit.
Websocket errors in the browser console. nginx is not sending /websocket to port 8072, or proxy_mode is off. Both are required.
Database port exposed. Adding ports: - "5432:5432" to the database service publishes it on every host interface, and Docker's iptables rules bypass ufw. Keep the database on an internal network and use docker compose exec db psql for access.
Data lost after docker compose down -v. The -v flag deletes named volumes. Never use it on production, and make sure backups exist outside Docker.
Two containers writing one filestore. Scaling the Odoo service to several replicas requires a shared filestore and sticky sessions or shared session storage. For most workloads, one container with more workers is simpler and faster.
Doing this with CICDoo
CICDoo runs this same model for you on Linux servers you own: each branch of your GitHub or GitLab repository becomes an Odoo instance in its own Docker container, with PostgreSQL alongside, and nginx with automatic SSL for your domains. You get live logs and a browser IDE in the console, start, stop, restart and rebuild actions, and scheduled backups to S3-compatible storage. It covers Community and Enterprise, 11.0 to the latest release, plus master. Read more on self-hosted Odoo with CICDoo or talk to us.