Skip to Content

Configuring Odoo Behind a Reverse Proxy: The Complete proxy_mode Guide

Configuring Odoo Behind a Reverse Proxy: The Complete proxy_mode Guide
Benjamin Akboka Apengu
August 6, 2026 · 10 min read


Getting Odoo running behind nginx or Cloudflare is a ten-minute job. Getting it running correctly is a different one, and the gap between the two is unusually quiet: Odoo will serve pages, users will log in, nothing will appear in the logs, and a handful of things will be subtly wrong for months.

If you’re here because your site works but the URLs are wrong somewhere (emails linking to http://, a sitemap that won’t go https, hreflang that never renders, the wrong IP in every log line), this is almost always the same single cause.

The short version

Setting proxy_mode = True in odoo.conf does nothing on its own. Odoo only honours it when the incoming request already carries an X-Forwarded-Host header. Miss that one header and the setting is ignored in full, including X-Forwarded-Proto, which is the one most guides tell you to set.

You need all three of these to be true:

  1. proxy_mode = True in odoo.conf
  2. Odoo restarted, because it’s a config-file option, not a runtime setting
  3. Your proxy sends both X-Forwarded-Host and X-Forwarded-Proto

Miss any one and Odoo builds every absolute URL from the address it was reached on internally (http://localhost:8069) rather than the address your users typed.

Why the extra header is required

This is the check Odoo runs on every single request, in Application.__call__ (odoo/http.py, Odoo 19.0):

if odoo.tools.config['proxy_mode'] and environ.get("HTTP_X_FORWARDED_HOST"):
...
ProxyFix(fake_app)(environ, fake_start_response)

Two conditions joined by and. proxy_mode is your config flag; HTTP_X_FORWARDED_HOST is the WSGI environment key for the X-Forwarded-Host header. If the header is absent, the middleware never runs, and no forwarded header is trusted at all: not the proto, not the for, not the host.

This is deliberate, and it’s the right design. Forwarded headers are client-supplied strings: anyone who can reach Odoo directly can claim to be any host, from any IP, over any scheme. Odoo’s own config help says so plainly:

--proxy-mode ... “Activate reverse proxy WSGI wrappers (headers rewriting). Only enable this when running behind a trusted web proxy!” Source: odoo/tools/config.py

The presence of X-Forwarded-Host is used as the signal that a proxy is genuinely in front. It isn’t a security boundary on its own (that has to come from your firewall), but it does mean the flag can’t be left on in a config file and silently start trusting headers from the open internet.

The practical consequence is what matters: a partially-configured proxy behaves exactly like an unconfigured one. There is no half-working state and no warning.

What Odoo actually trusts

When the gate opens, Odoo applies Werkzeug’s ProxyFix with fixed parameters:

ProxyFix = functools.partial(ProxyFix_, x_for=1, x_proto=1, x_host=1)

That single line determines everything your proxy config has to satisfy:

Header

Rewrites

Trusted?

X-Forwarded-For

REMOTE_ADDR, the client IP Odoo logs and rate-limits on

1 hop

X-Forwarded-Proto

wsgi.url_scheme, http vs https in every generated URL

1 hop

X-Forwarded-Host

HTTP_HOST, SERVER_NAME, SERVER_PORT

1 hop

X-Forwarded-Port

n/a

ignored

X-Forwarded-Prefix

n/a

ignored

Two of these deserve attention.

X-Forwarded-Port is not read. Odoo takes the port from the port component of X-Forwarded-Host instead. If you’re serving on a non-standard port, it belongs in that header (X-Forwarded-Host: erp.example.com:8443), not in X-Forwarded-Port, which Odoo discards.

X-Forwarded-Prefix is not read either, which means SCRIPT_NAME is never rewritten. Serving Odoo from a sub-path of a larger site (example.com/erp/) is not supported by this mechanism, however well your proxy rewrites the path. Odoo will keep generating root-relative URLs. Give Odoo its own hostname or subdomain.

The 1 is the part that bites: real client IPs

x_for=1 does not mean “take the first entry.” Werkzeug resolves it as values[-trusted]: the rightmost value in the header, counting back one position.

With a single proxy that’s correct. With two, it quietly isn’t.

A very common stack is Cloudflare in front of nginx. Cloudflare arrives with the visitor’s IP in X-Forwarded-For. nginx, configured the way nearly every tutorial writes it, then appends its own view of the connection:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # appends: two hops now

Odoo receives X-Forwarded-For: 203.0.113.9, 172.68.x.x, takes the rightmost value, and records Cloudflare’s edge IP as the client. Every log line, every login record, every rate limit and every audit trail now points at your CDN instead of at a user. Nothing breaks; the data is simply wrong, and it stays wrong retroactively.

Behind Cloudflare, hand Odoo the visitor directly:

# Cloudflare puts the true client IP in its own header
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;

The more robust version is nginx’s real_ip module with Cloudflare’s published IP ranges in set_real_ip_fromreal_ip_header CF-Connecting-IP, and then proxy_set_header X-Forwarded-For $remote_addr;. That way $remote_addr is already the visitor everywhere in your nginx config, including your own access logs. Cloudflare publishes those ranges and they change; pull them rather than pasting them.

The general rule: Odoo trusts exactly one hop, so make sure the last hop is telling the truth.

What a misconfigured proxy actually breaks

None of these announce themselves. This is the list to check against, because these symptoms are usually reported as four unrelated problems.

Absolute URLs are built from the wrong host. Anything Odoo generates outside the browser’s own address bar (password reset links, portal invitations, share links, og:url, emailed report links) is assembled from HTTP_HOST and the URL scheme. Wrong host, wrong scheme, wrong link. Users notice this first, and typically as “the reset link is broken.”

robots.txt and sitemap.xml emit http://. The sitemap controller builds <loc> values from url_root. If wsgi.url_scheme is http, every URL you submit to search engines is the wrong scheme.

The website’s hreflang block disappears entirely. On a multi-language site this one is genuinely invisible without viewing source. Odoo gates the hreflang tags on website._is_canonical_url(), which compares:

current_url = request.httprequest.url_root[:-1] + request.httprequest.environ['REQUEST_URI']
canonical_url = self.env['ir.http']._url_localized(..., canonical_domain=self.get_base_url())
return current_url == canonical_url

url_root comes from the WSGI environment, so it’s http:// behind an unfixed proxy. get_base_url() returns your configured domain, which is https://. They can never be equal, the check returns False on every page, and the hreflang block is skipped site-wide, on a site whose translations are otherwise perfectly configured.

Note this one is not fixable from the database. Setting web.base.url does not help, because nothing in the system parameters can change url_root. It comes from the WSGI environment, which is exactly what ProxyFix exists to correct. Time is regularly lost here, because web.base.url is the setting that sounds like it should be responsible. (The full diagnosis is its own post; link the hreflang guide here on publish.)

Multi-database host filtering selects the wrong database, or none. db_filter() resolves %h and %d from the environment:

host = request.httprequest.environ.get('HTTP_HOST', '')

HTTP_HOST is one of the values ProxyFix rewrites. Without it, every request appears to arrive at whatever internal address your proxy used, so a dbfilter keyed on hostname matches the same database for every domain, or matches nothing, and presents a database selector you thought you’d disabled. On a multi-tenant host this is the difference between routing by domain and not routing at all.

A caching wrinkle worth knowing. The sitemap is stored as an ir.attachment keyed on a hash of url_root. Fixing the scheme therefore generates a new cached sitemap immediately rather than making you wait out the TTL, but the stale http:// attachment stays in the database. Delete it, or you’ll keep finding it.

Cloudflare: use Full (strict), not Flexible

Cloudflare’s Flexible SSL mode encrypts browser→Cloudflare and then talks to your origin over plain HTTP. It’s presented as the zero-configuration option, and it interacts badly with a correctly-secured origin.

The failure is a redirect loop. Your origin, sensibly, redirects HTTP to HTTPS. Cloudflare, in Flexible mode, only ever connects over HTTP. So: Cloudflare requests over HTTP → origin returns 301 → https:// → Cloudflare requests again over HTTP → loop, until the browser gives up with ERR_TOO_MANY_REDIRECTS. The usual reaction is to remove the origin’s HTTPS redirect, which resolves the loop by making the origin permanently unencrypted.

The second problem is the one you can’t see: in Flexible mode the traffic between Cloudflare and your server crosses the public internet in clear text, session cookies included, while the browser displays a padlock.

Use Full (strict) with a valid certificate on the origin. Let’s Encrypt is free, or Cloudflare’s Origin CA certificates are issued for 15 years and are trusted by Cloudflare specifically for this purpose.

Websockets and the second port

Odoo runs its bus on a second process, on --gevent-port, 8072 by default (odoo/tools/config.py). Requests to /websocket must reach that port, not 8069, and must be allowed to upgrade the connection.

Get this wrong and the symptom is oddly specific: the application works, but live features quietly stop: chat and Discuss messages don’t arrive, notifications don’t appear, and the browser console fills with failed websocket connections. Long-running operations that report progress appear to hang.

A working nginx configuration

# Required for websocket upgrades
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}


upstream odoo { server 127.0.0.1:8069; }
upstream odoo_bus { server 127.0.0.1:8072; }


server {
listen 443 ssl;
http2 on;
server_name erp.example.com;

ssl_certificate /etc/letsencrypt/live/erp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/erp.example.com/privkey.pem;

# The four headers. X-Forwarded-Host is the one that opens the gate.
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # see the Cloudflare note above
proxy_set_header X-Real-IP $remote_addr;

# Long imports, big reports, PDF generation
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;

client_max_body_size 100m;

location / {
proxy_pass http://odoo;
proxy_redirect off;
}

location /websocket {
proxy_pass http://odoo_bus;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}

# Long-cache Odoo's hashed asset bundles
location ~* /web/(static|assets)/ {
proxy_pass http://odoo;
proxy_cache_valid 200 302 60m;
expires 864000;
}
}


server {
listen 80;
server_name erp.example.com;
return 301 https://$host$request_uri;
}

And in odoo.conf:

proxy_mode = True

Then restart Odoo. It is read at startup only.

Close the direct route. proxy_mode tells Odoo to trust forwarded headers; only your firewall stops someone sending them directly. Bind Odoo to loopback (http_interface = 127.0.0.1) or block 8069 and 8072 at the firewall. Otherwise anyone who can reach the origin can spoof both their IP and their hostname.

Verifying it, in three commands

Don’t verify this by loading the site in a browser: the browser hides precisely the thing that’s wrong. Check the URLs Odoo generates.

1. The one-line test. The sitemap is built from url_root, so it reports the scheme Odoo believes it’s serving:

curl -s -L https://erp.example.com/sitemap.xml | grep -o '<loc>[^<]*</loc>' | head -3

Still http://? url_root is still wrong, and nothing else is worth checking until that’s fixed.

2. robots.txt, which carries an absolute sitemap directive:

curl -s https://erp.example.com/robots.txt | grep -i sitemap

3. On a multi-language site, confirm hreflang returns, using curl rather than devtools, so you’re reading what a crawler receives rather than a rendered DOM:

curl -s -A "Googlebot" https://erp.example.com/ | grep -o '<link[^>]*hreflang[^>]*>'

Empty output on a site with more than one active language means the canonical comparison is still failing.

Re-run the first check with a query string (?utm_source=test). The original fault is scheme-dependent, and a partial fix that works on clean URLs is possible.

The part that isn’t in the config file

Everything above is a one-time job that stays correct until something changes: a new certificate, a CDN in front, a second database, an added language, a migration that resets a config file. Each of those can silently reopen the same gap, and none will show up as an error. The proxy layer’s failure mode is that it keeps working while quietly getting the details wrong, which is why these problems are usually found months later by someone auditing something else.

That’s the actual difference between Odoo running and Odoo running properly. It’s rarely dramatic. It’s a header nobody set, and it stays that way until someone reads the source.


Run it on your own server, without owning this problem

If you’d rather not maintain the proxy layer, the certificate renewals, the websocket routing and the header set yourself: Skysize BYOS runs on your server, in your country, under your control, and we handle the Odoo side of it, including all of the above. Built by ex-Odoo engineers.

Connect your server. Or, if you’d rather not run the machine either, see managed Odoo hosting.

Configuring Odoo Behind a Reverse Proxy: The Complete proxy_mode Guide
Benjamin Akboka Apengu
Writes about Odoo infrastructure at Skysize, a managed Odoo hosting provider based in Belgium serving businesses and agencies worldwide.