Odoo Docker Staging: Dev, Staging and Prod with Compose

Build an Odoo Docker staging setup next to dev and production with one base Compose file, per-environment override files and profiles.

Updated . 6 min read

Most Odoo projects need at least three copies of the same stack: a development setup on your laptop, an Odoo Docker staging environment where you test module updates against real data, and production. Keeping three separate Compose files in sync breaks down fast. This guide shows a cleaner layout for Odoo dev, staging and production: one base compose.yaml, a small override file per environment, and Compose profiles for optional tools. Examples use odoo:19 and postgres:16.

The base stack is the one from the Odoo Docker Compose guide.

Project layout

odoo-project/
├── compose.yaml            # shared by every environment
├── compose.override.yaml   # development (loaded automatically)
├── compose.staging.yaml
├── compose.prod.yaml
├── addons/                 # your custom modules
└── config/
    ├── dev/odoo.conf
    ├── staging/odoo.conf
    └── prod/odoo.conf

The base file holds what never changes between environments: images, the database connection, the healthcheck and the volumes. Each override adds what differs: published ports, which odoo.conf to mount, resource limits, and development options.

The base compose.yaml

compose.yaml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_DB: postgres
      POSTGRES_USER: odoo
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U odoo -d postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  odoo:
    image: odoo:19
    depends_on:
      db:
        condition: service_healthy
        restart: true
    environment:
      HOST: db
      USER: odoo
      PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
    volumes:
      - odoo-data:/var/lib/odoo
      - ./addons:/mnt/extra-addons

volumes:
  db-data:
  odoo-data:

There are no ports in the base file on purpose. When Compose merges files, it concatenates ports lists instead of replacing them, so a port published in the base would show up in every environment and you couldn't take it away in an override. The ${DB_PASSWORD:?...} syntax stops Compose with an error if the variable is missing, instead of silently starting with an empty password. Put the value in a .env file next to compose.yaml, a different one on each server, and keep it out of Git.

Development: compose.override.yaml

Compose reads compose.yaml and, if it exists, compose.override.yaml automatically. That makes the override file the natural place for development settings: a plain docker compose up on your laptop gets them, and you never type -f.

compose.override.yaml
services:
  odoo:
    command: ["odoo", "--dev=xml"]
    ports:
      - "8069:8069"
    volumes:
      - ./config/dev:/etc/odoo

  mailpit:
    image: axllent/mailpit
    profiles: ["mail"]
    ports:
      - "8025:8025"
  • --dev=xml makes Odoo read QWeb templates straight from your module's XML files instead of the database, so template edits show up without a module update. --dev=all adds auto-reload on Python changes and more debugging aids. Odoo's documentation is explicit: development features only, never in production.
  • ./config/dev is mounted at /etc/odoo, replacing the image's default config with yours.
  • mailpit is a local mail catcher with a web inbox on port 8025. It sits behind a profile, explained below.
config/dev/odoo.conf
[options]
addons_path = /mnt/extra-addons
data_dir = /var/lib/odoo
workers = 0
list_db = True
smtp_server = mailpit
smtp_port = 1025

Start development with:

docker compose up -d

Staging and production override files

Staging should look like production, only smaller, and it must never be able to email your customers.

compose.staging.yaml
services:
  odoo:
    ports:
      - "127.0.0.1:8070:8069"
      - "127.0.0.1:8073:8072"
    volumes:
      - ./config/staging:/etc/odoo:ro
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 2g
    restart: unless-stopped
  db:
    restart: unless-stopped

  mailpit:
    image: axllent/mailpit
    profiles: ["mail"]
    ports:
      - "127.0.0.1:8025:8025"
compose.prod.yaml
services:
  odoo:
    ports:
      - "127.0.0.1:8069:8069"
      - "127.0.0.1:8072:8072"
    volumes:
      - ./config/prod:/etc/odoo:ro
      - ./addons:/mnt/extra-addons:ro
    deploy:
      resources:
        limits:
          cpus: "2.0"
          memory: 4g
    restart: unless-stopped
  db:
    deploy:
      resources:
        limits:
          memory: 2g
    restart: unless-stopped
config/prod/odoo.conf
[options]
addons_path = /mnt/extra-addons
data_dir = /var/lib/odoo
proxy_mode = True
list_db = False
admin_passwd = change-this-to-a-long-random-value
workers = 4
max_cron_threads = 1
limit_memory_soft = 671088640
limit_memory_hard = 1073741824

Binding ports to 127.0.0.1 keeps Odoo off the public internet; a reverse proxy on the same host (Nginx, Caddy or Traefik v3) terminates HTTPS and forwards to it, with /websocket routed to port 8072. list_db = False hides the database manager, and the ./addons mount is redeclared read-only: volumes merge by container path, so this entry replaces the base one. Use the same config/staging/odoo.conf with fewer workers and no real outgoing mail server. Worker and memory numbers are explained in Odoo Docker memory limits.

Run an environment by listing its files in order. Later files override earlier ones:

# staging
docker compose -f compose.yaml -f compose.staging.yaml up -d

# production
docker compose -f compose.yaml -f compose.prod.yaml up -d

Once you pass -f, Compose no longer loads compose.override.yaml, so development settings never leak into staging or production. To avoid typing the file list on each server, set it once in that server's .env:

.env (production server)
COMPOSE_FILE=compose.yaml:compose.prod.yaml
COMPOSE_PROJECT_NAME=odoo-prod
DB_PASSWORD=a-long-random-password

Now docker compose up -d, docker compose logs -f odoo and every other command use the production files. Use : as the separator on Linux and macOS and ; on Windows.

How Compose merges override files

Knowing the merge rules saves a lot of confusion:

AttributeMerge behavior
image, command, restart and other single valuesLater file replaces the value
environment, labelsMerged by variable name; later file wins per key
volumesMerged by container path; later file wins per path
ports, expose, dnsConcatenated: both lists apply

Relative paths in every file resolve against the first file's folder, not the override's. Before you deploy, print the merged result and read it:

docker compose -f compose.yaml -f compose.prod.yaml config

Optional services with Compose profiles

Profiles switch individual services on and off within one environment. A service with a profiles list only starts when one of its profiles is active. A service without profiles always starts, whichever profiles you activate. That second rule is easy to get backwards:

# db and odoo only (mailpit has a profile, so it stays off)
docker compose up -d

# db, odoo AND mailpit
docker compose --profile mail up -d

There is no profile that starts "only" mailpit, because db and odoo have no profile and always come along. To start a single profiled service, name it: docker compose up -d mailpit starts it (plus anything in its depends_on) even though the profile is off. You can also activate profiles with COMPOSE_PROFILES=mail in .env, or all of them with --profile "*".

Good candidates for profiles in an Odoo project: a mail catcher, a database admin UI, a one-off restore job, or a test runner. Keep Odoo and PostgreSQL unprofiled so they always start.

Refresh staging with production data

Staging is only useful with realistic data. To refresh it:

  1. On production, dump the database and archive its filestore, as shown in Odoo Docker volumes and backups.
  2. Copy both files to the staging server and restore them into the staging stack, keeping the filestore folder name equal to the database name.
  3. Neutralize the copy before anyone logs in:
    docker compose -f compose.yaml -f compose.staging.yaml run --rm odoo odoo neutralize -d mydb
    odoo neutralize disables outgoing mail servers, scheduled actions and other integrations, so staging can't send invoices to real customers or charge real payment methods.
  4. Start staging and test your module updates against it before running them on production.

If staging and production share one host, give each its own project name (COMPOSE_PROJECT_NAME or -p). Compose prefixes container, network and volume names with it, so odoo-staging and odoo-prod never touch each other's data. Different host ports (8070 and 8069 above) avoid port clashes. A separate server for production is still the safer choice: a runaway staging test can't slow down live users.

When something misbehaves in one environment only, compare the output of config for both and read the Odoo logs with docker compose logs -f odoo.

Staging without the extra servers

On Easy Instance, you can create a staging copy of any instance: a private copy where you test module updates before they reach production, alongside daily backups and GitHub deploys. It is free to create; its usage is added to your next bill. Here's how staging works. You can try it free for 10 days with no credit card, and pricing is per Odoo worker, not per user.

Rather not run the server yourself?

Get managed Odoo hosting with backups, SSL, monitoring and updates handled for you. Free for 10 days.