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
  1. The short version
  2. Directory layout
  3. The docker-compose.yml
  4. The odoo.conf inside the container
  5. nginx in front
  6. First start and creating the database
  7. Upgrading modules with -u
  8. Backing up the volumes
  9. Resource limits, logging and restarts
  10. Common pitfalls
  11. Doing this with CICDoo

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.0 tracks 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 backend network is internal, so PostgreSQL has no route to the internet and no published port. Only Odoo can reach it. Odoo also joins frontend so nginx can reach it.
  • HOST, PORT, USER, PASSWORD are the environment variables the official image's entrypoint turns into --db_host, --db_port, --db_user and --db_password. You can put these in odoo.conf instead, but not both with different values.
  • /web/health exists in Odoo 16 and later and returns a small JSON payload without touching a database. The official image includes curl.
  • shm_size avoids "could not resize shared memory segment" errors from PostgreSQL parallel queries in containers, where the default /dev/shm is 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_limit is 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. Keep workers x limit_memory_soft comfortably below mem_limit, so Odoo recycles its own workers before Docker kills everything.
  • CPU. With cpus: 4, size workers for 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-size rotation above, the default json-file driver grows without limit and eventually fills the disk. Read logs with docker compose logs --since 1h odoo, or ship them to Loki or a similar system.
  • Restarts. restart: unless-stopped brings the stack back after a reboot or crash. Combined with the healthcheck, docker compose ps shows 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.

Part of Self-hosted Odoo, done right

FAQ

Questions, answered

Is the official Odoo Docker image good for production?

Yes, if you configure it properly. Mount your own odoo.conf with workers enabled, list_db False, a strong admin_passwd and proxy_mode True, use named volumes for the database and filestore, pin the image version, and put a reverse proxy in front for TLS and the websocket.

Where does the Odoo Docker image store the filestore?

In /var/lib/odoo inside the container, under filestore/<database_name>. Mount a named volume at /var/lib/odoo so attachments survive container rebuilds, and back it up together with the database.

How do I add custom modules to Odoo in Docker?

Mount your addons directory at /mnt/extra-addons (or any path) and list every directory that directly contains modules in addons_path in odoo.conf. Then update the apps list or install the module with -i from a one-off container.

How do I update an Odoo module in Docker Compose?

Run a one-off container with docker compose run --rm odoo odoo -d yourdb -u module_name --stop-after-init, then restart the Odoo service. Take a database and filestore backup before updating in production.

Why do I get permission denied errors with the Odoo Docker image?

The container runs as the odoo user (uid 101 in current images), and bind-mounted host directories are often owned by root or your own user. Change the ownership of the mounted directories to that uid, or use named volumes, which Docker creates with the right ownership.

Should I set workers when running Odoo in Docker?

Yes. Without workers, Odoo runs in threaded mode with no per-request memory or time limits and serves the websocket differently. Set workers based on the CPU and memory the container can use, roughly (cores x 2) + 1, and route /websocket to port 8072.

Run Odoo like this, without doing it by hand

CICDoo turns every step in this guide into a push to Git, on servers you own. Talk to an engineer about your setup.

No per-seat fees. Your servers stay yours.