Why Odoo security in production is systematically neglected
Most Odoo deployments reach production with the default configuration from the installation wizard, an Nginx copied from a five-year-old tutorial, and the database master password set to «admin». That combination is the dream of any automated attacker scanning IP ranges looking for exposed Odoo instances.
Odoo exposes several attack vectors by default that must be explicitly closed before putting an instance on the Internet: the database manager accessible from outside, the login endpoint with no attempt limit, the exact Odoo version in the HTTP headers, and a TLS configuration that may be stuck on decade-old parameters. This guide walks through each of those vectors and gives the specific configuration to close them.
The context is real: the architecture described here is the one I apply in production for clients such as Rehabmedic (healthcare sector, patient data, GDPR compliance) and the one underpinning the server that hosts skanndar.top.
Odoo attack surface: what can be attacked
Before hardening, you need to understand what needs to be hardened. In a standard Odoo installation with Nginx as a proxy, the attack vectors are:
- /web/database/manager: web interface to create, delete, duplicate and restore databases. If accessible from the Internet, an attacker can attempt to create their own database, restore a dump with altered data, or simply delete the existing ones.
- /web/database/backup and /web/database/restore: endpoints that allow downloading and restoring full database backups with only the master password.
- Login endpoint /web/login: without rate limiting, it accepts brute-force attempts against any user account, including the administrator's.
- HTTP version headers: Odoo includes headers by default that reveal the exact version of the system, allowing attackers to search for known vulnerabilities in that specific version.
- Database port 5432: if PostgreSQL is accessible from outside the server (a common mistake), anyone with credentials can connect directly to the database.
- Port 8069 exposed directly: Odoo is not designed to receive external traffic directly; the Odoo HTTP protocol lacks the protections added by a reverse proxy.
- Xmlrpc: the endpoints
/xmlrpc/2/commonand/xmlrpc/2/objectprovide programmatic access to almost all Odoo functionality. Without IP restrictions or proper API authentication, they are another brute-force vector.
Operating system hardening
Non-root user for Odoo
Odoo must never run as root. If the Odoo process is compromised, the attacker must not have system privileges. The standard practice is to create a dedicated user without a login shell:
# Crear usuario y grupo odoo sin home ni shell interactiva
useradd -r -s /usr/sbin/nologin -d /opt/odoo -m odoo
# Asignar ownership de los directorios de Odoo
chown -R odoo:odoo /opt/odoo
chown -R odoo:odoo /var/log/odoo
chmod 750 /opt/odoo
chmod 750 /var/log/odoo
# El fichero odoo.conf no debe ser legible por otros usuarios
chown odoo:odoo /etc/odoo/odoo.conf
chmod 640 /etc/odoo/odoo.conf
In Docker environments, this is equivalent to defining the user in the Dockerfile (USER odoo) and ensuring the entrypoint does not run sudo or change users at runtime.
Firewall with UFW or iptables
The default policy must deny all incoming traffic and open only the necessary ports. Odoo ports 8069 and 8072 must never be open in the public firewall: only Nginx should communicate with them, and it must do so via the loopback interface or internal Docker network:
# UFW: política restrictiva
ufw default deny incoming
ufw default allow outgoing
# Solo SSH, HTTP y HTTPS son accesibles desde Internet
ufw allow 22/tcp # SSH (mejor cambiar al puerto no estándar)
ufw allow 80/tcp # HTTP → redirigir a HTTPS en Nginx
ufw allow 443/tcp # HTTPS
# PostgreSQL solo desde localhost o red interna
# NO abrir 5432 al exterior
# Activar
ufw enable
ufw status verbose
If the server is on a VPC or private cloud network (AWS, Hetzner, OVH), also add security group rules at the provider level as a second layer.
fail2ban: automatic blocking of brute-force attacks
fail2ban reads Nginx and Odoo logs and automatically blocks IPs that exceed a threshold of failed attempts. It is especially effective against automated scanners that try known credentials against the Odoo login endpoint:
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 3600 # 1 hora bloqueado por defecto
findtime = 600 # ventana de detección: 10 minutos
maxretry = 5 # 5 intentos fallidos antes del ban
ignoreip = 127.0.0.1/8 ::1 # no banear localhost
# Jail para Nginx: ataques genéricos y escaneos
[nginx-http-auth]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 5
# Jail específica para el login de Odoo
# (requiere que los logs de Odoo registren intentos fallidos)
[odoo-login]
enabled = true
port = http,https
logpath = /var/log/odoo/odoo.log
filter = odoo-login
maxretry = 5
bantime = 7200
The fail2ban filter for Odoo looks for the string that Odoo writes to the log when a login fails:
# /etc/fail2ban/filter.d/odoo-login.conf
[Definition]
failregex = .*Login failed for.*<HOST>
.*Authentication failure.*<HOST>
ignoreregex =
Verify that fail2ban is running and view active bans:
systemctl status fail2ban
fail2ban-client status odoo-login
fail2ban-client status nginx-http-auth
Secure Nginx configuration
Modern TLS and HSTS
The default Nginx TLS configuration is too permissive. You must enforce TLS 1.2 and 1.3 exclusively, use secure ciphers and enable HSTS with preload so that browsers remember the domain must only be accessed over HTTPS:
# /etc/nginx/conf.d/ssl-params.conf
# Incluir este fichero desde los bloques server con: include conf.d/ssl-params.conf;
# Solo TLS 1.2 y 1.3 (eliminar TLS 1.0 y 1.1)
ssl_protocols TLSv1.2 TLSv1.3;
# Cifrados modernos — Mozilla Intermediate Configuration (2024)
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Sesiones TLS
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
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;
# Parámetros DH (generar con: openssl dhparam -out /etc/nginx/dhparam.pem 4096)
ssl_dhparam /etc/nginx/dhparam.pem;
HTTP security headers
Security headers are the second line of defence against XSS, clickjacking and content injection attacks. Odoo already sends some by default, but they must be reinforced in Nginx to ensure they are applied even if Odoo fails:
# /etc/nginx/conf.d/security-headers.conf
# Incluir con: include conf.d/security-headers.conf;
# Evitar que la página sea cargada en un iframe (clickjacking)
add_header X-Frame-Options "SAMEORIGIN" always;
# Evitar que el navegador detecte el MIME type de forma automática
add_header X-Content-Type-Options "nosniff" always;
# XSS Protection (para navegadores antiguos sin CSP)
add_header X-XSS-Protection "1; mode=block" always;
# HSTS: forzar HTTPS durante 1 año, incluir subdominios y preload
# AVISO: una vez activado, NO se puede revertir sin esperar el tiempo de expiración
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Referrer Policy: no filtrar la URL completa en peticiones cross-origin
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Permissions Policy: desactivar funcionalidades del navegador no necesarias
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
# Content Security Policy (CSP) — ajustar a los dominios reales de tu instancia
# Este es un punto de partida conservador; afina según los errores de consola
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self';" always;
# Ocultar la versión de Nginx
server_tokens off;
Important note on CSP in Odoo: Odoo uses unsafe-inline and unsafe-eval extensively in its JavaScript. A strict CSP policy will break parts of the UI. The CSP shown above is the minimum functional policy; in production it is recommended to monitor CSP violations in report-only mode before activating the policy in blocking mode.
Rate limiting to prevent brute force and DDoS
Nginx rate limiting restricts how many requests an IP can make per second to specific endpoints. The most critical targets are the login endpoint and the XML-RPC API endpoints:
# /etc/nginx/nginx.conf (bloque http)
http {
# ...
# Zonas de rate limiting
# Zona para el endpoint de login: 5 req/seg por IP, buffer de 10MB
limit_req_zone $binary_remote_addr zone=odoo_login:10m rate=5r/m;
# Zona para la API (XML-RPC / JSON-RPC): 30 req/seg por IP
limit_req_zone $binary_remote_addr zone=odoo_api:10m rate=30r/s;
# Zona global para todo el tráfico Odoo
limit_req_zone $binary_remote_addr zone=odoo_global:10m rate=100r/s;
# Tamaño máximo de body (evitar ataques de upload masivo)
client_max_body_size 64m;
client_body_timeout 30s;
client_header_timeout 30s;
keepalive_timeout 30s;
send_timeout 30s;
# ...
}
Complete Nginx block for Odoo in production
This is the complete virtual server configuration file. It combines all the above measures with the reverse proxy to Odoo and the explicit blocking of the database manager:
# /etc/nginx/sites-available/odoo.conf
# Redirigir HTTP → HTTPS
server {
listen 80;
server_name tudominio.com www.tudominio.com;
return 301 https://tudominio.com$request_uri;
}
# Redirigir www → non-www (SEO: evitar contenido duplicado)
server {
listen 443 ssl;
server_name www.tudominio.com;
include conf.d/ssl-params.conf;
ssl_certificate /etc/letsencrypt/live/tudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tudominio.com/privkey.pem;
return 301 https://tudominio.com$request_uri;
}
# Servidor principal
server {
listen 443 ssl;
server_name tudominio.com;
# Certificado TLS (Certbot / Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/tudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tudominio.com/privkey.pem;
include conf.d/ssl-params.conf;
# Headers de seguridad
include conf.d/security-headers.conf;
# Logs
access_log /var/log/nginx/odoo_access.log combined;
error_log /var/log/nginx/odoo_error.log warn;
# -----------------------------------------------------------------
# BLOQUEAR el gestor de bases de datos desde el exterior
# Esta es una de las medidas más importantes para Odoo
# -----------------------------------------------------------------
location ~* ^/web/database/ {
# Permitir solo desde localhost (para acceso de administración local)
allow 127.0.0.1;
deny all;
# Opcional: devolver 404 en lugar de 403 para no revelar que existe
# return 404;
}
# Bloquear también los endpoints de backup y restore explícitamente
location = /web/database/backup {
allow 127.0.0.1;
deny all;
}
location = /web/database/restore {
allow 127.0.0.1;
deny all;
}
# -----------------------------------------------------------------
# Rate limiting en endpoint de login
# -----------------------------------------------------------------
location = /web/login {
limit_req zone=odoo_login burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8069;
include conf.d/proxy-params.conf;
}
# -----------------------------------------------------------------
# Rate limiting en endpoints XML-RPC / JSON-RPC
# -----------------------------------------------------------------
location ~* ^/xmlrpc/ {
limit_req zone=odoo_api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8069;
include conf.d/proxy-params.conf;
}
location ~* ^/web/dataset/ {
limit_req zone=odoo_api burst=50 nodelay;
proxy_pass http://127.0.0.1:8069;
include conf.d/proxy-params.conf;
}
# -----------------------------------------------------------------
# Longpolling (chat, notificaciones en tiempo real)
# -----------------------------------------------------------------
location /longpolling/ {
proxy_pass http://127.0.0.1:8072;
include conf.d/proxy-params.conf;
proxy_read_timeout 3600s;
proxy_connect_timeout 3600s;
}
# -----------------------------------------------------------------
# Ficheros estáticos: cache agresiva
# -----------------------------------------------------------------
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
proxy_pass http://127.0.0.1:8069;
include conf.d/proxy-params.conf;
expires 30d;
add_header Cache-Control "public, immutable";
# Los headers de seguridad no se aplican aquí (no son HTML)
}
# -----------------------------------------------------------------
# Tráfico general Odoo
# -----------------------------------------------------------------
location / {
limit_req zone=odoo_global burst=100 nodelay;
proxy_pass http://127.0.0.1:8069;
include conf.d/proxy-params.conf;
}
}
The proxy parameters file centralises the common directives and avoids repetition:
# /etc/nginx/conf.d/proxy-params.conf
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;
proxy_redirect off;
proxy_buffering off; # necesario para longpolling
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;
Secure Odoo configuration (odoo.conf)
Odoo itself has configuration options that significantly reduce the attack surface. These are the most important for a production environment:
# /etc/odoo/odoo.conf
[options]
; -------------------------------------------------------
; SEGURIDAD: parámetros críticos
; -------------------------------------------------------
; Contraseña maestra de la base de datos
; NUNCA dejar 'admin' o en blanco en producción
; Generar con: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
admin_passwd = REEMPLAZAR_CON_TOKEN_ALEATORIO_SEGURO
; Desactivar el listado de bases de datos disponibles en el login
; Evita que cualquier visitante vea los nombres de tus BDs
list_db = False
; Filtro de base de datos: solo servir una BD específica por dominio
; Evita que rutas maliciosas accedan a otras BDs del servidor
dbfilter = ^odoo_produccion$
; No mostrar errores de traza completa al usuario final
; (los errores detallados van al log, no al navegador)
debug_mode = False
; -------------------------------------------------------
; PROXY: necesario cuando Nginx actúa como proxy reverso
; -------------------------------------------------------
proxy_mode = True
; -------------------------------------------------------
; WORKERS Y RENDIMIENTO
; En producción usar workers múltiples (no 0)
; Regla de referencia: 2 * núcleos_CPU + 1
; -------------------------------------------------------
workers = 5
max_cron_threads = 2
; Límites de memoria para prevenir ataques de agotamiento de recursos
limit_memory_hard = 2684354560 ; 2.5 GB
limit_memory_soft = 2147483648 ; 2 GB
limit_request = 8192
limit_time_cpu = 120
limit_time_real = 240
; -------------------------------------------------------
; BASE DE DATOS
; -------------------------------------------------------
db_host = 127.0.0.1
db_port = 5432
db_user = odoo_prod
db_password = CONTRASENA_BD_SEGURA
db_maxconn = 64
; -------------------------------------------------------
; LOGS
; -------------------------------------------------------
log_level = warn
logfile = /var/log/odoo/odoo.log
logrotate = True
; -------------------------------------------------------
; DIRECTORIOS
; -------------------------------------------------------
addons_path = /opt/odoo/addons,/opt/odoo/custom_addons
data_dir = /var/lib/odoo
On list_db = False: this option is especially important. Without it, the Odoo login screen displays a dropdown with all the database names on the PostgreSQL server. An attacker who knows the database names can direct their attacks far more efficiently.
On dbfilter: the filter limits which database can be selected based on the request domain. Even if you only have one database, enabling this filter adds an extra layer: even if someone manages to bypass the web manager, the HTTP request will only be able to access the database that matches the regex pattern.
PostgreSQL security
PostgreSQL has its own security configuration that goes beyond what Odoo manages:
# /etc/postgresql/16/main/postgresql.conf
# Solo escuchar en localhost — NUNCA en 0.0.0.0 para producción
listen_addresses = '127.0.0.1'
# Logging de conexiones y sentencias lentas
log_connections = on
log_disconnections = on
log_min_duration_statement = 1000 # loguear queries > 1 segundo
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
# SSL obligatorio para conexiones remotas (si las hubiera)
ssl = on
# /etc/postgresql/16/main/pg_hba.conf
# Política de autenticación: solo md5/scram-sha-256 desde localhost
# TYPE DATABASE USER ADDRESS METHOD
local all postgres peer
local all all md5
host odoo_produccion odoo_prod 127.0.0.1/32 scram-sha-256
# NO añadir líneas con 0.0.0.0/0 ni con trust
Encrypted and verified backups
An unencrypted backup is a GDPR risk: if the backup file is accidentally exposed (in a misconfigured S3, on a lost drive), all your clients' data is compromised. Backups must be encrypted at rest and in transit.
#!/bin/bash
# /opt/scripts/backup-odoo-cifrado.sh
# Ejecutar desde cron: 0 2 * * * /opt/scripts/backup-odoo-cifrado.sh
set -euo pipefail
DB_NAME="odoo_produccion"
DB_USER="odoo_prod"
BACKUP_DIR="/opt/backups"
S3_BUCKET="s3://tu-bucket-backups/odoo/"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/odoo_${DB_NAME}_${TIMESTAMP}.dump"
ENCRYPTED_FILE="${BACKUP_FILE}.gpg"
# ID de la clave GPG del receptor (administrador del servidor)
GPG_RECIPIENT="backup@tudominio.com"
mkdir -p "${BACKUP_DIR}"
echo "[$(date)] Iniciando backup de ${DB_NAME}..."
# 1. Dump en formato custom de PostgreSQL (comprimido y resumible)
pg_dump -U "${DB_USER}" -h 127.0.0.1 -Fc "${DB_NAME}" -f "${BACKUP_FILE}"
# 2. Verificar integridad del dump
if ! pg_restore --list "${BACKUP_FILE}" > /dev/null 2>&1; then
echo "[ERROR] El dump no es válido. Abortando."
rm -f "${BACKUP_FILE}"
exit 1
fi
BACKUP_SIZE=$(stat -c%s "${BACKUP_FILE}")
if [ "${BACKUP_SIZE}" -lt 1048576 ]; then
echo "[ERROR] El dump es sospechosamente pequeño: ${BACKUP_SIZE} bytes."
exit 1
fi
# 3. Cifrar con GPG (cifrado asimétrico — la clave privada no está en el servidor)
gpg --trust-model always \
--recipient "${GPG_RECIPIENT}" \
--output "${ENCRYPTED_FILE}" \
--encrypt "${BACKUP_FILE}"
# 4. Subir a S3 (las credenciales AWS deben estar en ~/.aws/credentials o en rol IAM)
aws s3 cp "${ENCRYPTED_FILE}" "${S3_BUCKET}" --storage-class STANDARD_IA
# 5. Eliminar el dump sin cifrar localmente
rm -f "${BACKUP_FILE}"
# 6. Limpiar backups locales cifrados de más de 7 días
find "${BACKUP_DIR}" -name "*.dump.gpg" -mtime +7 -delete
echo "[$(date)] Backup completado: ${ENCRYPTED_FILE} (${BACKUP_SIZE} bytes) → ${S3_BUCKET}"
The key to this process is asymmetric encryption with GPG: only the holder of the private key (the administrator) can decrypt the backup. Even if someone gains access to the S3 bucket or the disk where the encrypted files are stored, the data is unreadable.
GDPR and ENS: considerations for Odoo instances in Spain
Companies in Spain that process personal data in Odoo (clients, employees, patients in the healthcare sector) are subject to the General Data Protection Regulation (GDPR) and, if they are service providers to Public Administrations, also to the National Security Framework (ENS).
The technical measures described in this guide cover the technical controls of the GDPR (Article 32), specifically:
- Pseudonymisation and encryption: encrypted backups and TLS in transit fulfil the requirement for encryption both at rest and in transit.
- Confidentiality and integrity: server hardening (non-root user, firewall, PostgreSQL without external exposure) ensures that only authorised systems access the data.
- Availability and resilience: verified backups and fail2ban, which prevents availability attacks, contribute to this requirement.
- Restoration capability: regular backups with integrity verification fulfil the requirement to be able to restore data availability in the event of an incident.
Additionally, it is recommended to:
- Enable the Odoo audit log (OCA
auditlogmodule) to record which user modified which data and when. Essential to demonstrate access control in the event of an inspection by the data protection authority. - Configure the password policy in Odoo (Settings > Technical > Security > Password Policy): minimum length, complexity, expiry.
- Enable two-factor authentication (2FA) for administrator accounts and users with access to particularly sensitive data.
- Review Odoo group permissions periodically: the principle of least privilege means a sales user must not have access to payroll, and a warehouse user must not see accounting data.
Security checklist: before going to production
| Comprovació | Expected status |
|---|---|
| Odoo runs as a non-root user | Verify with ps aux | grep odoo |
| Port 8069/8072 not exposed in firewall | ufw status — 8069 must not appear |
| Port 5432 not exposed externally | ss -tlnp | grep 5432 — only 127.0.0.1 |
| /web/database/manager blocked from outside | curl -I https://tudominio.com/web/database/manager → 403 |
| list_db = False in odoo.conf | Login does not show database dropdown |
| admin_passwd is not 'admin' | Random token of 32+ characters |
| TLS 1.0 and 1.1 disabled | nmap --script ssl-enum-ciphers tudominio.com |
| HSTS active with max-age=31536000 | curl -I https://tudominio.com | grep Strict-Transport |
| server_tokens off | Server header must not reveal the Nginx version |
| fail2ban active and monitoring Odoo | fail2ban-client status odoo-login |
| Encrypted and verified backups daily | Cron active, verify S3 or remote destination |
| 2FA enabled for administrator accounts | Settings > Users > Enable 2FA |
Conclusió
Hardening is not a one-off project: it is an ongoing discipline. The configurations in this guide cover the most frequent attack vectors and the most common compliance requirements in Spain, but security requires updates when new vulnerabilities appear in Odoo (follow the Odoo S.A. Security Advisory), when TLS standards change, or when the system architecture evolves.
If you are deploying Odoo in a regulated sector (healthcare, finance, public administration) or simply want to ensure your instance is not the next client in a security incident press release, the investment in proper hardening from day one is orders of magnitude cheaper than incident response.