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.
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.
# ── 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;
}
}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:
| Endpoint | What It Exposes | Attack Vector | Nginx Rule |
|---|---|---|---|
/web/database/* | Full database management: create, drop, backup, restore, duplicate | Brute-force master password → drop production DB | deny all; return 404; |
/web/webclient/version_info | Exact Odoo version, server info, installed modules | Fingerprinting → target known CVEs for that exact version | deny all; return 404; |
/web/debug | Debug mode activation, asset debugging, profiler | Information disclosure, performance degradation | deny all; return 404; |
/web/login | Authentication endpoint | Credential stuffing, brute-force | limit_req 5r/m |
/jsonrpc / /xmlrpc | Full API surface (CRUD on all models) | API abuse, data exfiltration via stolen credentials | limit_req 30r/m |
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:
# 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;
}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
| Zone | Rate | Burst | Protected Endpoints | Why This Rate |
|---|---|---|---|---|
odoo_login | 5 req/min | 3 | /web/login, /web/session/authenticate, /web/reset_password | A human types a password in 2-3 seconds. 5/min allows normal usage, blocks automated tools doing 1000/min. |
odoo_api | 30 req/min | 10 | /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_general | 10 req/sec | 20 | All other routes | Normal 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:
# 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:
[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"][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 =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.
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:
# ── 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;# 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:
| Metric | Without Nginx Cache | With Nginx Cache | Improvement |
|---|---|---|---|
| Static JS/CSS | 120ms (through Python) | 2ms (from cache) | 60x faster |
| Product images | 80ms (ORM query + resize) | 5ms (from cache) | 16x faster |
| Page load (50 assets) | 3.2s | 0.8s | 4x faster |
| Bandwidth (gzip) | 2.4MB per page load | 680KB per page load | 72% reduction |
3 Nginx + Odoo Mistakes That Cause Silent Failures in Production
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.
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.
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.
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.
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.
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.
What a Properly Configured Reverse Proxy Saves Your Business
Nginx is free software. The ROI comes from what it prevents:
Blocking /web/database and rate-limiting login removes the two most common attack vectors against Odoo installations.
Static caching + gzip cuts average page load from 3.2s to 0.8s. Users spend less time waiting, more time working.
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.
Optimization Metadata
Complete Nginx reverse proxy config for Odoo 19. Block /web/database, rate-limit login and API endpoints, enable SSL, gzip, and static caching.
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"