Odoo Load Balancing on Docker Swarm with Traefik

Odoo load balancing on Docker Swarm: routing mesh vs Traefik, routing /websocket to port 8072, sticky sessions, shared storage and health checks.

Updated . 6 min read

Odoo load balancing on Docker Swarm can happen at two layers. Swarm's built-in routing mesh balances TCP connections. A reverse proxy such as Traefik balances HTTP requests. Odoo needs the second one, because it serves normal pages and its websocket on different ports, and only an HTTP-aware proxy can route by path. This guide explains how Odoo handles traffic, what the routing mesh can and cannot do, and how to set up Traefik v3 with the /websocket route, sticky sessions and health checks so you can run Odoo on multiple servers.

How Odoo serves HTTP and websocket traffic

Odoo listens on two ports, and which one does what depends on the workers setting:

ModePort 8069Port 8072 (gevent)
workers = 0 (threaded, default)All traffic, including /websocketNot used
workers > 0 (multi-process)Normal HTTP requests/websocket (live chat, Discuss, notifications)

In production you run with workers > 0, so the proxy must send paths starting with /websocket to port 8072 and everything else to 8069. If /websocket goes to 8069 in multi-process mode, the browser cannot hold the connection open. Discuss messages and notifications then stop arriving in real time, and the browser console fills with websocket errors. Odoo 16 renamed the port option from longpolling_port to gevent_port. The default is still 8072.

Behind any proxy, also start Odoo with --proxy-mode (proxy_mode = True in odoo.conf). Odoo then trusts the X-Forwarded-* headers, so it builds correct HTTPS URLs and logs the real client IP. Only enable it when Odoo really sits behind a proxy.

What the Swarm routing mesh does

When you publish a port on a Swarm service, every node accepts connections on that port and forwards them to a running task, even if that node runs no task itself. This is the ingress routing mesh. It works at the connection level (layer 4):

  • It spreads TCP connections across tasks. It does not look at HTTP requests, so many requests reusing one keep-alive connection all reach the same task.
  • It cannot route by path, so it cannot send /websocket to 8072 and / to 8069 on the same public port.
  • It does not terminate TLS and has no sticky sessions.
  • In the default ingress mode, the source address is rewritten, so Odoo sees an internal IP instead of the visitor's IP.

The routing mesh is fine for a quick test with one replica and workers = 0. For anything users depend on, put an HTTP proxy in front.

Odoo load balancing with Traefik v3

Traefik reads service labels from the Swarm API and builds its routes from them. In Swarm mode, labels go under deploy.labels (on the service), not on the container. Traefik also needs loadbalancer.server.port set explicitly for every Swarm service. The stack below runs Traefik on a manager, Odoo with two replicas, and PostgreSQL 16:

odoo-stack.yml
services:
  traefik:
    image: traefik:v3.4
    command:
      - --providers.swarm=true
      - --providers.swarm.exposedByDefault=false
      - --providers.swarm.network=web
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.le.acme.tlschallenge=true
      - --certificatesresolvers.le.acme.email=${ACME_EMAIL}
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
    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: [web]
    deploy:
      placement:
        constraints: [node.role == manager]

  odoo:
    image: odoo:18
    command: ["--workers=4", "--max-cron-threads=1", "--proxy-mode"]
    environment:
      HOST: db
      USER: odoo
      PASSWORD: ${PG_PASSWORD}
    volumes:
      - odoo-data:/var/lib/odoo   # must be shared storage when replicas > 1
    networks: [web, internal]
    deploy:
      replicas: 2
      labels:
        - traefik.enable=true
        # normal HTTP -> 8069
        - traefik.http.routers.odoo.rule=Host(`erp.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.services.odoo.loadbalancer.sticky.cookie=true
        - traefik.http.services.odoo.loadbalancer.sticky.cookie.name=odoo_lb
        - traefik.http.services.odoo.loadbalancer.sticky.cookie.secure=true
        - traefik.http.services.odoo.loadbalancer.sticky.cookie.httponly=true
        # websocket -> 8072
        - traefik.http.routers.odoo-ws.rule=Host(`erp.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

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: odoo
      POSTGRES_PASSWORD: ${PG_PASSWORD}
      POSTGRES_DB: postgres
    volumes:
      - pg-data:/var/lib/postgresql/data
    networks: [internal]
    deploy:
      placement:
        constraints: [node.labels.db == true]

networks:
  web:
    name: web
    driver: overlay
  internal:
    driver: overlay

volumes:
  letsencrypt:
  odoo-data:
  pg-data:

Deploy it from a manager with docker stack deploy -c odoo-stack.yml odoo. A few points about the file:

  • The odoo-ws router has a longer rule than odoo, so Traefik gives it a higher priority and websocket requests match it first. Traefik passes websocket upgrades through without extra configuration.
  • Traefik publishes 80 and 443 in host mode, so connections reach it directly with the visitor's real IP instead of going through the routing mesh. With --proxy-mode, Odoo then logs that IP from X-Forwarded-For.
  • Odoo sits on two overlay networks. --providers.swarm.network=web tells Traefik which one to use to reach it. The network has a fixed name so the stack prefix does not change it.
  • By default Traefik sends traffic straight to task IPs, not to Swarm's virtual IP. That is what makes sticky cookies work. If you set traefik.swarm.lbswarm=true, Swarm does the balancing and stickiness is lost.
  • For real deployments, pass the database password as a Swarm secret instead of an environment variable. See Odoo Docker secrets in Swarm.

Sticky sessions and shared storage

Odoo stores sessions as files in its data directory (/var/lib/odoo/sessions in the official image), next to the filestore that holds attachments. With two replicas and separate volumes, a user who logs in on replica A and whose next request reaches replica B is logged out. Their uploaded attachments also exist only on the replica that received them.

The right fix is shared storage: mount the same /var/lib/odoo on every replica, usually over NFS (Odoo filestore on NFS). The sticky cookie in the stack above is a useful extra on top of that. It keeps each browser on one replica, which spreads load evenly and avoids racing writes to the same session file. Do not rely on sticky sessions alone: when a replica restarts, its users move to another one, and without shared storage their session and files are gone.

Websocket notifications work across replicas. Odoo's bus uses PostgreSQL LISTEN/NOTIFY, so a message posted on one replica reaches browsers connected to any other.

Health checks so only ready replicas get traffic

Odoo 17 and later answer GET /web/health with HTTP 200 when the server is up. Add a health check so Swarm marks a replica healthy only after Odoo has loaded. Traefik only routes to healthy tasks.

healthcheck for the odoo service
    healthcheck:
      test: ["CMD", "curl", "-fs", "http://localhost:8069/web/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 60s

The start_period matters for Odoo: loading a large registry can take a minute or more, and failed checks during that window do not count.

Replicas or more workers?

Balancing across replicas is not the only way to scale. One container with --workers=8 on a bigger server is often simpler than four replicas with two workers each, and needs no shared storage. Add replicas when one server is not enough, or when you want an Odoo instance to keep serving while one node is down. Scale Odoo compares both approaches.

Let someone else run the proxy

On Easy Instance, each Odoo instance gets its own Traefik routes, with HTTPS and the /websocket path already sent to port 8072 when the instance runs multiple workers (8069 when it runs threaded). Plans are priced per Odoo worker at $30 per month (pricing). You can test it on a 10-day free trial without a credit card.

Rather not run the server yourself?

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