Deploy Odoo Docker Stack: Odoo + PostgreSQL on Swarm

Deploy an Odoo docker stack on Swarm: Odoo 19 and PostgreSQL 16, a Swarm secret, Traefik v3 HTTPS and rolling updates with automatic rollback.

Updated . 6 min read

This guide shows how to deploy an Odoo Docker stack on Swarm: Odoo 19 and PostgreSQL 16 defined in one stack file, the database password kept in a Swarm secret, HTTPS from Traefik v3, and rolling updates that roll back on their own if something fails. Every command uses current Docker syntax. The stack file is plain Compose-spec YAML, so if you already run Odoo with Docker Compose, most of it will look familiar.

Before you start

  • A Swarm with at least one manager (docker swarm init). To add nodes, see Docker Swarm cluster for Odoo.
  • A DNS record (for example odoo.example.com) pointing to the node that will run Traefik. Ports 80 and 443 must be open on that node.
  • Run every command below on a manager node.

Prepare the Swarm: network, secret and node labels

Create an overlay network that Traefik and Odoo will share:

docker network create --driver overlay --attachable traefik-public

Store the database password as a Swarm secret. Secrets are encrypted in the Swarm's Raft store and only mounted into containers that ask for them, under /run/secrets/:

openssl rand -base64 24 | tr -d '\n' | docker secret create odoo_db_password -

This stack uses local volumes, which stay on one node. So pin PostgreSQL and Odoo to the node that holds their data:

docker node update --label-add odoo.data=true <node-name>

If you want Odoo to move freely between nodes, put its filestore on shared storage instead. See Odoo filestore on NFS.

Deploy Traefik v3 as the entry point

Traefik v3 has a dedicated swarm provider. In v2 this was the docker provider with swarmMode=true, and older tutorials still show that. Traefik needs the Docker API of a manager, so it is constrained to managers. Its ports use host mode so Odoo sees the real client IP.

traefik.yml
services:
  traefik:
    image: traefik:v3.3
    command:
      - --providers.swarm=true
      - --providers.swarm.exposedByDefault=false
      - --providers.swarm.network=traefik-public
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.le.acme.email=${ACME_EMAIL}
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
    ports:
      - target: 80
        published: 80
        mode: host
      - target: 443
        published: 443
        mode: host
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    networks:
      - traefik-public
    deploy:
      placement:
        constraints:
          - node.role == manager

networks:
  traefik-public:
    external: true

volumes:
  letsencrypt:
export ACME_EMAIL=you@example.com
docker stack deploy -c traefik.yml traefik

docker stack deploy substitutes ${VARIABLES} from your shell environment. Unlike docker compose, it does not read a .env file automatically. Mounting the Docker socket gives Traefik full control of the Docker API. On shared clusters, put a socket proxy in front of it.

Write odoo.conf for a proxied, multi-worker Odoo

odoo.conf
[options]
db_host = db
db_port = 5432
db_user = odoo
db_name = odoo
dbfilter = ^odoo$
list_db = False
proxy_mode = True
data_dir = /var/lib/odoo
addons_path = /mnt/extra-addons
workers = 2
max_cron_threads = 1
gevent_port = 8072

The file has no password. The official Odoo image reads PASSWORD_FILE at startup and passes the password to Odoo, so the secret never appears in the config. proxy_mode makes Odoo trust Traefik's forwarded headers. list_db = False hides the database manager. With workers above 0, Odoo serves its websocket on gevent_port, which is why Traefik needs a second route below. For sizing workers and memory limits, see Odoo server configuration.

The Odoo swarm stack file

odoo.yml
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: odoo
      POSTGRES_DB: postgres
      POSTGRES_PASSWORD_FILE: /run/secrets/odoo_db_password
    secrets:
      - odoo_db_password
    volumes:
      - pg-data:/var/lib/postgresql/data
    networks:
      - backend
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U odoo"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      placement:
        constraints:
          - node.labels.odoo.data == true
      restart_policy:
        condition: any

  odoo:
    image: odoo:19.0
    environment:
      PASSWORD_FILE: /run/secrets/odoo_db_password
    secrets:
      - odoo_db_password
    configs:
      - source: odoo_conf
        target: /etc/odoo/odoo.conf
    volumes:
      - odoo-data:/var/lib/odoo
    networks:
      - backend
      - traefik-public
    stop_grace_period: 120s
    healthcheck:
      test: ["CMD", "python3", "-c", "import urllib.request as u,sys;sys.exit(0 if u.urlopen('http://localhost:8069/web/health',timeout=8).getcode()==200 else 1)"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.labels.odoo.data == true
      restart_policy:
        condition: any
        delay: 5s
      update_config:
        order: start-first
        failure_action: rollback
        monitor: 180s
      rollback_config:
        order: start-first
      resources:
        limits:
          cpus: "2"
          memory: 3g
        reservations:
          cpus: "0.5"
          memory: 1g
      labels:
        - "traefik.enable=true"
        - "traefik.http.routers.odoo.rule=Host(`odoo.example.com`)"
        - "traefik.http.routers.odoo.entrypoints=websecure"
        - "traefik.http.routers.odoo.tls.certresolver=le"
        - "traefik.http.routers.odoo.service=odoo"
        - "traefik.http.services.odoo.loadbalancer.server.port=8069"
        - "traefik.http.routers.odoo-ws.rule=Host(`odoo.example.com`) && PathPrefix(`/websocket`)"
        - "traefik.http.routers.odoo-ws.entrypoints=websecure"
        - "traefik.http.routers.odoo-ws.tls.certresolver=le"
        - "traefik.http.routers.odoo-ws.service=odoo-ws"
        - "traefik.http.services.odoo-ws.loadbalancer.server.port=8072"

networks:
  backend:
    driver: overlay
  traefik-public:
    external: true

volumes:
  pg-data:
  odoo-data:

secrets:
  odoo_db_password:
    external: true

configs:
  odoo_conf:
    name: odoo_conf_v1
    file: ./odoo.conf

Things that differ from a Compose file:

  • Traefik labels go under deploy.labels (service labels), not top-level labels. In Swarm mode Traefik also needs the loadbalancer.server.port label.
  • depends_on, build, container_name and restart are ignored by docker stack deploy. The Odoo image's entrypoint already waits for PostgreSQL. If it gives up, Swarm restarts the task.
  • There is no top-level version: key. The Compose specification doesn't use it.
  • The health check uses Python because the Odoo image always includes it. start-first plus that health check means a new Odoo container must answer /web/health before the old one stops.

Deploy the Odoo docker stack

docker stack deploy -c odoo.yml --detach=false odoo

--detach=false (Docker 26 and later) waits until the services converge, so errors show up right away. Add --with-registry-auth if your Odoo image is in a private registry. Then create the database once. The command runs inside the Odoo container on the node that holds it:

docker exec -it $(docker ps -q -f name=odoo_odoo) \
  sh -c 'odoo -c /etc/odoo/odoo.conf -d odoo -i base \
         --db_password="$(cat /run/secrets/odoo_db_password)" \
         --workers=0 --no-http --stop-after-init'

Open https://odoo.example.com and log in as admin with password admin, then change that password immediately. To keep the master password (admin_passwd) out of plain config, see Odoo docker secrets in Swarm.

Check the stack

docker stack services odoo
docker service ps odoo_odoo --no-trunc
docker service logs -f --tail 100 odoo_odoo

docker service ps shows each task's node, state and full error message, for example no suitable node when a placement label is missing. For deeper troubleshooting, see troubleshoot Odoo on Docker Swarm.

Update Odoo, config and resources

  • New image or limits: edit odoo.yml and run the same docker stack deploy command again. Only services whose definition changed are updated.
  • New odoo.conf: Swarm configs can't be changed once created. Bump the name (odoo_conf_v2) and redeploy. The rolling update swaps the file in.
  • Module upgrades: run odoo -u <module> the same way as the database creation above, then restart with docker service update --force odoo_odoo.
  • Back out a bad change: docker service rollback odoo_odoo.

Remove the stack

docker stack rm odoo

This removes the stack's services, its backend network and the configs it created. Named volumes (pg-data, odoo-data) and the external secret are kept. Back them up before deleting anything by hand.

When you would rather not run the stack

Easy Instance runs each customer's Odoo as its own Swarm stack using these patterns. How Easy Instance runs Odoo on Docker Swarm explains the setup. If you want the result without operating a cluster, compare pricing or start a 10-day free trial, no credit card needed.

Rather not run the server yourself?

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