Odoo Server Placement on Swarm: Node Labels by Region

Odoo server placement on Docker Swarm: label nodes by region, pin Odoo and PostgreSQL with constraints, and spread replicas with placement preferences.

Updated . 5 min read

Odoo server placement in a Swarm cluster decides which physical server, and which region, each Odoo instance runs in. Getting it right keeps an instance close to its users and its data inside the country it must stay in, and keeps Odoo next to its PostgreSQL database instead of a continent away. Swarm handles placement with three tools: node labels to describe your servers, placement constraints for hard rules, and placement preferences for soft spreading. This guide shows how to use them to place Odoo instances by region, and the mistakes that move instances to the wrong place.

Why placement matters for Odoo

By default the Swarm scheduler puts a task on any active node with enough free resources, favoring nodes that run fewer tasks. For a stateless web app that is fine. For Odoo it causes problems:

  • Latency: Odoo's web client makes many requests per screen. 150 ms of round trip per request feels slow in a way 20 ms does not.
  • Database distance: Odoo makes many small queries per request. If the Odoo container runs in one data center and PostgreSQL in another, every page waits on the network between them.
  • Data residency: some customers need their database and filestore to stay in a specific country or region.
  • Local storage: an Odoo instance whose filestore sits on a node's local volume finds an empty filestore if it is rescheduled elsewhere.

Describe your servers with Swarm node labels

Node labels are key-value pairs you attach to nodes from a manager. They mean nothing to Swarm until a service refers to them, so you can choose any scheme. For regional Odoo hosting, a simple one:

docker node update --label-add region=eu-central --label-add zone=eu-central-a worker-1
docker node update --label-add region=eu-central --label-add zone=eu-central-b worker-2
docker node update --label-add region=us-east --label-add zone=us-east-a worker-3
docker node update --label-add db=true worker-1

Check what a node carries:

docker node inspect --format '{{ json .Spec.Labels }}' worker-1

Remove a label with docker node update --label-rm zone worker-1. Use node labels (node.labels.*), not Engine labels (engine.labels.*), for placement. Node labels can only be changed by a manager. Engine labels are set in each server's daemon config, so anyone with access to that server can change them.

Hard rules: placement constraints

A constraint is a condition a node must meet, or the task does not run there. You can match on these attributes:

AttributeExample
node.idnode.id==2ivku8v2gvtg4
node.hostnamenode.hostname!=worker-2
node.rolenode.role==worker
node.platform.os / node.platform.archnode.platform.arch==x86_64
node.labels.<key>node.labels.region==eu-central
engine.labels.<key>engine.labels.operatingsystem==ubuntu-24.04

The operators are == and !=. With several constraints, a node must match all of them. Here is an Odoo stack pinned to the EU region, with its database pinned to the labeled database node:

odoo-eu.yml
services:
  odoo:
    image: odoo:18
    command: ["--workers=4", "--proxy-mode"]
    environment:
      HOST: db
      USER: odoo
      PASSWORD: ${PG_PASSWORD}
    volumes:
      - odoo-data:/var/lib/odoo
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.role == worker
          - node.labels.region == eu-central

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

volumes:
  odoo-data:
  pg-data:

The same rules on the command line use --constraint:

docker service create --name odoo-eu \
  --constraint 'node.role==worker' \
  --constraint 'node.labels.region==eu-central' \
  odoo:18

If no node matches, Swarm does not fall back to another node. The task stays Pending, and docker service ps --no-trunc odoo_odoo shows an error such as "no suitable node". That is the behavior you want for data residency: an instance that cannot run in its region should not quietly run somewhere else.

Use positive rules, not exclusion lists

A tempting shortcut is to keep general instances out of dedicated regions with a list such as node.labels.region != us-east for each region. This breaks the day you add a region. The new region's nodes do not match any exclusion that already exists, so the scheduler is free to move older instances onto them during the next reschedule or node drain. Their databases and filestores stay behind.

Give every node a label, and give every service exactly one positive rule. General-purpose nodes get region=shared, and general instances use node.labels.region == shared. Dedicated nodes get their own region value, and only that region's instances name it. New regions then never attract existing instances.

Soft spreading: placement preferences

Preferences do not exclude nodes. They tell the scheduler how to spread tasks among the nodes that passed the constraints. The only strategy is spread, which balances tasks evenly across the values of a label. To spread two Odoo replicas across zones inside one region:

deploy section of the odoo service
    deploy:
      replicas: 2
      placement:
        constraints:
          - node.labels.region == eu-central
        preferences:
          - spread: node.labels.zone

On the CLI that is --placement-pref 'spread=node.labels.zone'. Spreading is best effort: if one zone has no capacity, both replicas can still land in the other. Nodes that do not have the label are treated as one more group, which can skew the spread, so label every eligible node.

To guarantee that two replicas never share a node, add a hard limit with docker service create --replicas-max-per-node 1 (or docker service update --replicas-max-per-node 1 on an existing service). Replicas only help Odoo if they share the filestore, so read Odoo filestore on NFS before scaling past one. For spreading managers and replicas to survive a zone failure, see Odoo high availability.

Combine placement with resource reservations

Constraints decide where a task may go. Reservations make sure the node has room for it. Reserve memory for each Odoo container so the scheduler stops placing instances on a node that is already full:

    deploy:
      resources:
        reservations:
          memory: 2G
        limits:
          memory: 3G

If no node in the region has 2 GB free, the task stays pending instead of overloading a server. Size reservations from your Odoo worker count (Odoo Docker memory limit).

Moving an Odoo instance to another region

Changing the constraint is the last step, not the first:

docker service update \
  --constraint-rm 'node.labels.region==eu-central' \
  --constraint-add 'node.labels.region==us-east' \
  odoo_odoo

Run this only after the database and filestore exist in the new region. The order: take a backup, restore the database and filestore in the target region, update the database host the instance uses, then move the Odoo service. If you flip the label first, Odoo starts next to an empty volume or a database on another continent.

On Easy Instance, self-serve instances run in Falkenstein, Germany. If you need Odoo closer to your users, or your data kept in a specific country, a dedicated server can be set up in a Hetzner or AWS location of your choice, and the instance, its database and its files stay there (FAQ, pricing). You can try the self-serve version with 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.