Last updated: 2026-08-09
Docker Compose is a reasonable production path for Odoo 19 when you want a reproducible, version-controlled deployment. Here is the compose file we actually use, with the details that matter for production, not a demo config that breaks on the first upgrade.
The complete docker-compose.yml
version: "3.8"
services:
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: a_real_generated_password
POSTGRES_DB: postgres
volumes:
- odoo-db-data:/var/lib/postgresql/data
shm_size: 256mb
odoo:
image: odoo:19.0
restart: unless-stopped
depends_on:
- db
ports:
- "127.0.0.1:8069:8069"
- "127.0.0.1:8072:8072"
environment:
HOST: db
USER: odoo
PASSWORD: a_real_generated_password
volumes:
- odoo-web-data:/var/lib/odoo
- ./custom-addons:/mnt/extra-addons
- ./config:/etc/odoo
command: ["odoo", "--workers=4", "--limit-time-cpu=600", "--limit-time-real=1200"]
volumes:
odoo-db-data:
odoo-web-data:
Why the ports are bound to 127.0.0.1
Binding 8069 and 8072 to 127.0.0.1 instead of exposing them on all interfaces means Docker never opens these ports to the outside network directly. Nginx, running on the host (or in its own container on a shared network) is the only thing that talks to Odoo, over localhost. This is a meaningful security default, not exposing your application server's raw HTTP port to the internet, SSL termination and rate limiting stay entirely in Nginx's hands.
If Nginx itself is also containerised rather than running on the host, the binding pattern changes slightly, you'd instead put both services on a shared user-defined Docker network and reference the Odoo service by its Compose service name (odoo) rather than 127.0.0.1, since containers on the same network resolve each other by service name, and there's no host port to publish for Odoo at all in that topology, only Nginx's 80/443 need publishing to the host.
Environment variables vs a mounted config file
The compose file above uses environment variables (HOST, USER, PASSWORD) for the database connection, which the official Odoo image reads directly. This is fine for the database credentials specifically, but for everything else, worker counts, timeouts, proxy mode, addons paths, a mounted config file is more maintainable than a long list of command-line flags, since it can be version-controlled and diffed the same way as the compose file itself. We use the flag-based command array above for brevity, in an actual production deployment we'd mount a real odoo.conf the same way the bare-metal install does, with proxy_mode = True set explicitly, see our bare-metal install post for the exact config file contents, the settings are identical, only the delivery mechanism (file vs flags) differs.
Persistent volumes: what actually needs to survive a container recreate
odoo-db-data: the PostgreSQL data directory. Losing this loses your database. Never run without this as a named volume or bind mount.odoo-web-data: this is where Odoo's filestore lives inside the container (/var/lib/odoo), attachments and uploaded files. Losing this loses every uploaded document and image, even though the database itself would survive../custom-addons: a bind mount, not a named volume, so your custom module source lives in your own version-controlled directory on the host, not hidden inside Docker's volume storage.
Nginx in front, with SSL
upstream odoo {
server 127.0.0.1:8069;
}
upstream odoochat {
server 127.0.0.1:8072;
}
server {
listen 443 ssl;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location / {
proxy_pass http://odoo;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /websocket {
proxy_pass http://odoochat;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
}
Ownership and permissions inside the container
The official odoo:19.0 image runs as the odoo user internally by default, so bind-mounted directories like ./custom-addons need to be readable by whatever UID that maps to on the host. If you hit permission errors on module installs from a bind-mounted addons path, check the UID mismatch first:
docker exec -it odoo_odoo_1 id
# compare the UID against ls -la on the host directory
If the UIDs don't match and you can't easily change the host directory's ownership (a shared CI runner, for example), the more portable fix is setting the container's user explicitly to match the host UID rather than the reverse:
odoo:
image: odoo:19.0
user: "1000:1000" # match your host addons directory's owner UID:GID
...
Healthchecks, so Compose actually knows when Odoo is ready
Without a healthcheck, Compose considers a container "up" the moment the process starts, not when Odoo has actually finished initializing and is accepting requests. That gap matters if you're scripting deploys or waiting for the stack to be ready before running a smoke test. Add:
odoo:
...
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8069/web/login"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
The start_period matters more than it looks, Odoo's own startup (loading modules, warming caches) can take well past 30 seconds on a server with a large filestore or many installed modules, and a healthcheck without a generous start period will mark the container unhealthy during completely normal startup.
Applying a version upgrade under Compose
One advantage of the Compose approach over a bare-metal install: an Odoo version bump is a one-line change to the image tag, not a full reinstall. But the sequence still matters, don't just edit the tag and run docker compose up -d blind:
# 1. Back up first, always, see the automated backups post
# 2. Pull the new image explicitly so you can see what's actually changing
docker compose pull odoo
# 3. Stop the stack cleanly rather than a hard kill
docker compose stop odoo
# 4. Bring it back up with the new image and force the module list upgrade
docker compose run --rm odoo odoo --update=all --stop-after-init -d your_db
# 5. Once the upgrade completes cleanly, start normally
docker compose up -d
Running the upgrade as a one-off docker compose run with --stop-after-init first, rather than letting the normal service start trigger it, means you see the full upgrade log and a clean exit code before the stack is serving live traffic again. This is the same discipline as a bare-metal upgrade, Compose doesn't remove the need for a staging rehearsal, it just makes the mechanics faster to repeat.
Common mistakes
- Not setting workers/limit_time flags. The Compose default single-threaded mode is fine for a quick test, never for production concurrency. Set them explicitly in the
commandas shown above. - Using bind mounts for the database data directory. Named volumes handle PostgreSQL's file permission requirements more predictably across host OS differences, prefer them over a raw bind mount for
odoo-db-dataspecifically. - Forgetting shm_size on the database service. PostgreSQL under Docker's default shared memory limit (64MB) can fail on larger queries with cryptic "could not resize shared memory segment" errors. 256MB is a safe production baseline.
- No backup strategy for named volumes. A named volume isn't automatically backed up anywhere. See our automated backup post for the actual dump-and-upload pipeline.
- Running
docker compose downinstead ofstopduring routine maintenance.downremoves the containers and the default network, which is usually fine since named volumes survive it, but it's an easy way to accidentally wipe an unnamed anonymous volume or a bind mount typo you didn't notice. Preferstop/startfor routine restarts and reservedownfor genuine teardowns. - Treating the container's internal filestore path as fixed. If you ever migrate away from Docker to a bare-metal install, the filestore path inside the container (
/var/lib/odoo/filestore/<db_name>) needs to be copied to wherever your new install expects it, it isn't automatically compatible just because both are "Odoo 19."
Sources: Odoo Docker installation documentation, Nginx documentation.