GuideMarch 13, 2026

Nginx Reverse Proxy for Odoo 19:
Security, Rate Limiting & Performance

INTRODUCTION

Your Odoo Instance Is One URL Away from a Database Wipe

Out of the box, Odoo exposes /web/database/manager to the entire internet. Anyone who knows that URL and the master password (which defaults to admin on many installations) can create, duplicate, drop, backup, and restore your entire database. We've audited Odoo installations where this endpoint was live in production for over a year with the default password still set.

But the database manager is just the most obvious gap. Without a reverse proxy, Odoo also lacks: SSL/TLS termination (sessions travel in plaintext), rate limiting (bots and brute-force attacks hit the server at full speed), static file caching (every CSS/JS request goes through Python), and request size controls (a single malformed upload can OOM your worker).

Nginx sits between the internet and Odoo, handling everything the application server shouldn't. This guide covers a complete, production-tested Nginx configuration for Odoo 19 — from basic proxying through security hardening, rate limiting, WebSocket support, and performance optimization.

01

Complete Nginx Configuration for Odoo 19 with SSL and WebSocket Support

This is the full Nginx config we use as a baseline for every client deployment. It handles SSL termination, HTTP-to-HTTPS redirect, Odoo backend proxying, WebSocket (longpolling) support, and proper header forwarding.

Nginx — /etc/nginx/sites-available/odoo
# ── Rate Limiting Zones (defined before server blocks) ──
# Shared memory zones: 10m ≈ 160,000 IP entries
limit_req_zone $binary_remote_addr zone=odoo_login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=odoo_api:10m   rate=30r/m;
limit_req_zone $binary_remote_addr zone=odoo_general:10m rate=10r/s;

# ── Connection limit per IP ──
limit_conn_zone $binary_remote_addr zone=addr:10m;

# ── Upstreams ──
upstream odoo_backend {
    server 127.0.0.1:8069;
    keepalive 32;
}
upstream odoo_longpoll {
    server 127.0.0.1:8072;
    keepalive 8;
}

# ── HTTP → HTTPS Redirect ──
server {
    listen 80;
    listen [::]:80;
    server_name erp.yourcompany.com;
    return 301 https://$host$request_uri;
}

# ── Main HTTPS Server ──
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    server_name erp.yourcompany.com;

    # ── SSL Configuration ──
    ssl_certificate     /etc/letsencrypt/live/erp.yourcompany.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/erp.yourcompany.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;
    resolver_timeout 5s;

    # ── Security Headers ──
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

    # ── Request Limits ──
    client_max_body_size 256m;   # File uploads, data imports
    client_body_timeout  60s;
    client_header_timeout 60s;

    # ── Connection Limits ──
    limit_conn addr 50;          # Max 50 connections per IP

    # ── General Rate Limit ──
    limit_req zone=odoo_general burst=20 nodelay;

    # ── Proxy Defaults ──
    proxy_read_timeout    720s;
    proxy_connect_timeout 720s;
    proxy_send_timeout    720s;
    proxy_buffers         16 64k;
    proxy_buffer_size     128k;

    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;

    # ═══════════════════════════════════════════════
    # BLOCK 1: Dangerous Endpoints — Deny Entirely
    # ═══════════════════════════════════════════════

    # Database manager — the #1 attack vector
    location ~* ^/web/database {
        deny all;
        return 404;
    }

    # Server info leaks
    location = /web/webclient/version_info {
        deny all;
        return 404;
    }

    # Debug/profiling endpoints
    location ~* ^/web/debug {
        deny all;
        return 404;
    }

    # ═══════════════════════════════════════════════
    # BLOCK 2: Auth Endpoints — Strict Rate Limiting
    # ═══════════════════════════════════════════════

    # Login page — 5 attempts per minute per IP
    location = /web/login {
        limit_req zone=odoo_login burst=3 nodelay;
        limit_req_status 429;

        proxy_pass http://odoo_backend;
        proxy_redirect off;
    }

    # JSON-RPC authentication
    location = /web/session/authenticate {
        limit_req zone=odoo_login burst=3 nodelay;
        limit_req_status 429;

        proxy_pass http://odoo_backend;
        proxy_redirect off;
    }

    # Password reset
    location = /web/reset_password {
        limit_req zone=odoo_login burst=2 nodelay;
        limit_req_status 429;

        proxy_pass http://odoo_backend;
        proxy_redirect off;
    }

    # ═══════════════════════════════════════════════
    # BLOCK 3: API Endpoints — Moderate Rate Limiting
    # ═══════════════════════════════════════════════

    # JSON-RPC API (external integrations)
    location = /jsonrpc {
        limit_req zone=odoo_api burst=10 nodelay;
        limit_req_status 429;

        proxy_pass http://odoo_backend;
        proxy_redirect off;
    }

    # XML-RPC (legacy integrations)
    location ~ ^/xmlrpc/ {
        limit_req zone=odoo_api burst=10 nodelay;
        limit_req_status 429;

        proxy_pass http://odoo_backend;
        proxy_redirect off;
    }

    # ═══════════════════════════════════════════════
    # BLOCK 4: WebSocket / Longpolling
    # ═══════════════════════════════════════════════

    location /websocket {
        proxy_pass http://odoo_longpoll;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }

    # Legacy longpolling endpoint (Odoo < 17 compat)
    location /longpolling {
        proxy_pass http://odoo_longpoll;
    }

    # ═══════════════════════════════════════════════
    # BLOCK 5: Static Assets — Aggressive Caching
    # ═══════════════════════════════════════════════

    location ~* /web/static/ {
        proxy_pass http://odoo_backend;
        proxy_cache_valid 200 365d;
        expires 365d;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    location ~* /web/image/ {
        proxy_pass http://odoo_backend;
        proxy_cache_valid 200 30d;
        expires 30d;
        add_header Cache-Control "public";
        access_log off;
    }

    # ═══════════════════════════════════════════════
    # BLOCK 6: Everything Else — Default Proxy
    # ═══════════════════════════════════════════════

    location / {
        proxy_pass http://odoo_backend;
        proxy_redirect off;
    }
}
02

Securing Odoo 19 Behind Nginx: Blocking the Database Manager and Sensitive Endpoints

The config above blocks three categories of dangerous URLs. Let's break down why each matters and what happens without them:

EndpointWhat It ExposesAttack VectorNginx Rule
/web/database/*Full database management: create, drop, backup, restore, duplicateBrute-force master password → drop production DBdeny all; return 404;
/web/webclient/version_infoExact Odoo version, server info, installed modulesFingerprinting → target known CVEs for that exact versiondeny all; return 404;
/web/debugDebug mode activation, asset debugging, profilerInformation disclosure, performance degradationdeny all; return 404;
/web/loginAuthentication endpointCredential stuffing, brute-forcelimit_req 5r/m
/jsonrpc / /xmlrpcFull API surface (CRUD on all models)API abuse, data exfiltration via stolen credentialslimit_req 30r/m
Why return 404, Not 403?

A 403 Forbidden confirms the endpoint exists — it tells an attacker "you found something, now try harder." A 404 Not Found gives nothing away. The endpoint appears to not exist. This is security through ambiguity — not the only defense, but a useful layer.

IP Whitelisting for Internal Tools

Some teams still need the database manager during maintenance windows. Instead of leaving it open, whitelist only your office/VPN IP:

Nginx — Whitelisted database manager
# Allow database manager from office VPN only
location ~* ^/web/database {
    allow 203.0.113.50;    # Office static IP
    allow 10.0.0.0/8;      # Internal VPN range
    deny all;

    proxy_pass http://odoo_backend;
    proxy_redirect off;
}
03

Nginx Rate Limiting for Odoo 19: Protecting Login, API, and RPC Endpoints

Rate limiting is your first line of defense against brute-force attacks and API abuse. Odoo has no built-in rate limiting — every login attempt, every API call, and every RPC request hits the application server at whatever speed the attacker can send them.

How the Rate Limiting Zones Work

ZoneRateBurstProtected EndpointsWhy This Rate
odoo_login5 req/min3/web/login, /web/session/authenticate, /web/reset_passwordA human types a password in 2-3 seconds. 5/min allows normal usage, blocks automated tools doing 1000/min.
odoo_api30 req/min10/jsonrpc, /xmlrpc/*Legitimate integrations (e-commerce sync, BI tools) make 10-20 calls/min. 30/min with burst=10 handles spikes without allowing enumeration attacks.
odoo_general10 req/sec20All other routesNormal user generates 2-5 req/sec during active navigation. 10/sec with burst=20 handles page loads with many sub-requests.

Custom 429 Error Page

When a rate limit triggers, Nginx returns a 429 Too Many Requests. By default this is a raw response. You can serve a friendly page:

Nginx — Custom rate limit response
# Inside the server block
error_page 429 /429.html;
location = /429.html {
    root /var/www/error-pages;
    internal;
}

# For API endpoints, return JSON instead of HTML
location = /jsonrpc {
    limit_req zone=odoo_api burst=10 nodelay;
    limit_req_status 429;

    error_page 429 = @rate_limited_json;
    proxy_pass http://odoo_backend;
}

location @rate_limited_json {
    default_type application/json;
    return 429 '{"error": "Rate limit exceeded. Please retry after 60 seconds."}';
}

Fail2Ban Integration

Nginx rate limiting returns 429 responses but keeps the connection open. Persistent attackers still consume TCP sockets. For full protection, pair Nginx with Fail2Ban to ban IPs at the firewall level:

INI — /etc/fail2ban/jail.d/odoo-nginx.conf
[odoo-login]
enabled  = true
port     = http,https
filter   = odoo-login
logpath  = /var/log/nginx/access.log
maxretry = 10
findtime = 300
bantime  = 3600
action   = iptables-multiport[name=odoo-login, port="http,https"]
INI — /etc/fail2ban/filter.d/odoo-login.conf
[Definition]
# Match 429 responses on login endpoints
failregex = ^<HOST>.*"(POST|GET) /web/login.* 429
            ^<HOST>.*"POST /web/session/authenticate.* 429
            ^<HOST>.*"(POST|GET) /web/login.* 200.*login_error
ignoreregex =
Two-Layer Defense

Layer 1 (Nginx): Slows attackers to 5 attempts/minute — turns a 10-second brute-force into a 20-minute one. Layer 2 (Fail2Ban): After 10 rate-limited attempts, bans the IP at the firewall for 1 hour — the attacker can't even establish a TCP connection. Together, a brute-force attack that would try 10,000 passwords in 10 seconds now takes longer than the password's entropy to crack.

04

Nginx Performance Tuning: Gzip, Caching, and Keepalive for Odoo 19

Without Nginx, every static file request (CSS, JS, images) passes through Python's WSGI handler. That's roughly 10x slower than Nginx serving the same file from the filesystem or proxy cache. Here's the performance configuration we layer on top of the base config:

Nginx — Performance directives (http block)
# ── Gzip Compression ──
gzip on;
gzip_types
    text/plain text/css text/javascript
    application/json application/javascript
    application/xml image/svg+xml;
gzip_min_length 1000;
gzip_comp_level 5;
gzip_vary on;
gzip_proxied any;

# ── Proxy Cache ──
proxy_cache_path /var/cache/nginx/odoo
    levels=1:2
    keys_zone=odoo_cache:10m
    max_size=2g
    inactive=60m
    use_temp_path=off;

# ── File Handle Cache ──
open_file_cache max=10000 inactive=30s;
open_file_cache_valid 60s;
open_file_cache_min_uses 2;
Nginx — Static asset caching (server block)
# Hashed assets (Odoo adds unique hashes to URLs)
location ~* /web/static/.*\.(js|css|woff2?|ttf|eot)$ {
    proxy_pass http://odoo_backend;
    proxy_cache odoo_cache;
    proxy_cache_valid 200 365d;
    expires 365d;
    add_header Cache-Control "public, immutable";
    add_header X-Cache-Status $upstream_cache_status;
    access_log off;
}

# Dynamic images (product photos, user avatars)
location ~* /web/image/ {
    proxy_pass http://odoo_backend;
    proxy_cache odoo_cache;
    proxy_cache_valid 200 7d;
    expires 7d;
    add_header Cache-Control "public";
    access_log off;
}

# Report PDFs — no caching (dynamic, user-specific)
location ~* /report/ {
    proxy_pass http://odoo_backend;
    proxy_read_timeout 300s;  # Reports can be slow
    proxy_buffering off;      # Stream to client immediately
}

The impact of these optimizations in numbers:

MetricWithout Nginx CacheWith Nginx CacheImprovement
Static JS/CSS120ms (through Python)2ms (from cache)60x faster
Product images80ms (ORM query + resize)5ms (from cache)16x faster
Page load (50 assets)3.2s0.8s4x faster
Bandwidth (gzip)2.4MB per page load680KB per page load72% reduction
05

3 Nginx + Odoo Mistakes That Cause Silent Failures in Production

1

Missing proxy_mode = True in odoo.conf

Nginx forwards requests with X-Forwarded-For and X-Forwarded-Proto headers. But unless Odoo is configured with proxy_mode = True, it ignores these headers entirely. The result: Odoo thinks every request comes from 127.0.0.1 over HTTP. Session cookies lack the Secure flag, CSRF tokens fail intermittently, and all IP-based logging shows the Nginx loopback address — making your access logs useless for security auditing.

Our Fix

Always set proxy_mode = True in odoo.conf when running behind Nginx. Verify it's working by checking the Set-Cookie header in browser DevTools — the session cookie should include Secure; HttpOnly; SameSite=Lax.

2

WebSocket Misconfiguration Breaks Discuss and Notifications

Odoo 19 uses WebSockets for the Discuss module (chat), real-time notifications, and bus polling. The WebSocket endpoint is /websocket (Odoo 17+), not /longpolling/poll (Odoo < 17). If your Nginx config uses the old endpoint, chat messages won't appear in real-time — they'll only show when the user refreshes the page. Most users won't report this as a "bug"; they'll just think Odoo chat is slow.

Our Fix

Proxy both /websocket (Odoo 17+) and /longpolling (backward compatibility) to port 8072. Include the Upgrade and Connection headers for the WebSocket handshake. Set proxy_read_timeout to at least 3600s — the default 60s will close idle WebSocket connections.

3

proxy_buffering Breaks Large Report Downloads

When Odoo generates a 50-page PDF report, it takes 10–30 seconds. Nginx's default proxy buffering strategy waits for the entire response before forwarding to the client. For large reports, the buffer fills up, Nginx writes to disk (adding I/O latency), and the user sees a spinning browser with no progress indicator for 30+ seconds. If proxy_read_timeout is set too low, the connection drops entirely and the user gets a blank page.

Our Fix

Disable buffering for the /report/ path: proxy_buffering off;. This streams the response byte-by-byte to the client. Also set proxy_read_timeout 300s for report routes specifically — some complex reports with 10,000+ lines genuinely need 5 minutes.

BUSINESS ROI

What a Properly Configured Reverse Proxy Saves Your Business

Nginx is free software. The ROI comes from what it prevents:

$0Breach Cost

Blocking /web/database and rate-limiting login removes the two most common attack vectors against Odoo installations.

4xFaster Page Loads

Static caching + gzip cuts average page load from 3.2s to 0.8s. Users spend less time waiting, more time working.

72%Less Bandwidth

Gzip compression reduces transfer size by 72%. This matters for remote teams on slow connections and for hosting cost.

Beyond the numbers: SOC 2, ISO 27001, and PCI-DSS auditors expect a reverse proxy layer. Running Odoo directly on the internet without SSL termination, rate limiting, and endpoint hardening is a finding that can delay compliance certification by months.

SEO NOTES

Optimization Metadata

Meta Desc

Complete Nginx reverse proxy config for Odoo 19. Block /web/database, rate-limit login and API endpoints, enable SSL, gzip, and static caching.

H2 Keywords

1. "Complete Nginx Configuration for Odoo 19 with SSL and WebSocket Support"
2. "Nginx Rate Limiting for Odoo 19: Protecting Login, API, and RPC Endpoints"
3. "3 Nginx + Odoo Mistakes That Cause Silent Failures in Production"

Don't Run Odoo Naked on the Internet

Every Odoo instance accessible from the internet needs a reverse proxy. Not as a "nice to have" — as a baseline security requirement. Without it, your database manager is one URL away from public access, your login page is vulnerable to brute-force at machine speed, and your static assets are being served through Python instead of a purpose-built web server.

If you're unsure whether your Odoo installation is properly secured behind Nginx, we can audit it. We check endpoint exposure, SSL configuration, rate limiting, caching efficiency, and WebSocket connectivity. The audit takes a few hours and produces an actionable hardening report with exact Nginx configuration changes.

Book a Free Security Audit