Why your Odoo production instance needs a periodic audit
The vast majority of Odoo production instances in Spain have been running for months or years without anyone having carried out an in-depth review of whether the configuration is still appropriate. The system keeps responding, users keep working, and the feeling is that it "works". Until it doesn't.
The problem is that the deterioration of an Odoo instance is gradual and invisible. Queries that took 200 ms two years ago now take 3 seconds because the account_move table has grown from 50,000 to 800,000 records and the indices have not been reviewed. The backups that were set up at launch have gone six months without being verified and the last valid backup is four days old. Twelve third-party modules have been installed without a code audit, some with an unnecessary sudo in an API controller. The Odoo workers are configured for the original 4-core server, which now has 16 cores.
A technical audit is not a months-long project. It is a systematic review of between 4 and 8 hours of expert work that produces a prioritised findings report and an action plan. This article describes exactly how to conduct one.
Block 1: Performance and worker configuration
Odoo's performance depends on three variables that must be aligned: server resources, the worker configuration in odoo.conf, and database behaviour.
1.1 Verify the worker configuration
The Odoo reference formula for sizing workers is:
# workers = (cores * 2) + 1 (recomendación general)
# max_cron_threads = 1 o 2 (nunca 0 en producción)
# limit_memory_hard <= RAM_total / (workers + 2)
# Verificar configuración actual:
cat /etc/odoo/odoo.conf | grep -E 'workers|memory|cpu|limit|thread'
# Ver workers activos en tiempo real:
ps aux | grep odoo | grep -v grep | wc -l
# Ver uso de memoria por proceso:
ps aux --sort=-%mem | grep odoo | head -20
On an instance with 8 cores and 32 GB of RAM, the correct configuration is approximately: workers = 16, limit_memory_hard = 2684354560 (2.5 GB), limit_memory_soft = 2147483648 (2 GB). If the server has those resources and workers = 4 (the default configuration for many installations), 75% of the server's capacity is being left unused.
1.2 Detect slow queries in PostgreSQL
Enable log_min_duration_statement if not already active and analyse with pg_stat_statements:
-- Verificar si pg_stat_statements está activo:
SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements';
-- Si no está, activar (requiere reinicio de PG):
-- shared_preload_libraries = 'pg_stat_statements' en postgresql.conf
CREATE EXTENSION pg_stat_statements;
-- Top 15 queries más lentas (tiempo total acumulado):
SELECT
LEFT(query, 120) AS query_preview,
calls,
ROUND(total_exec_time::numeric, 2) AS total_ms,
ROUND(mean_exec_time::numeric, 2) AS mean_ms,
ROUND(stddev_exec_time::numeric, 2) AS stddev_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 15;
-- Top queries por llamadas (candidates para optimizar con índices):
SELECT
LEFT(query, 120) AS query_preview,
calls,
ROUND(mean_exec_time::numeric, 2) AS mean_ms
FROM pg_stat_statements
ORDER BY calls DESC
LIMIT 15;
1.3 Missing indices and bloated tables
-- Tablas más grandes (candidatas a revisar vacuum/autovacuum):
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) AS table_size,
n_dead_tup,
n_live_tup,
ROUND(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100, 2) AS dead_pct,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;
-- Índices no usados (candidatos a eliminar):
SELECT
schemaname,
tablename,
indexname,
idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;
Block 2: Security
Security of an Odoo production instance has four layers to audit: the server, Nginx, the Odoo configuration, and the installed custom modules.
2.1 Server security checklist
- Is port 8069 (Odoo) directly accessible from the internet? It must be blocked by the firewall; only Nginx should be able to connect to it.
- Is port 5432 (PostgreSQL) accessible from the internet? It must be closed. Only the application server should be able to connect.
- Are the system packages up to date?
apt list --upgradable 2>/dev/null | wc -l— if there are more than 20 packages pending update, review them. - Are there users with default SSH keys or weak passwords? Audit
/etc/ssh/sshd_config:PasswordAuthentication nomust be active. - Is fail2ban configured for the SSH port and for Nginx? IPs that brute-force should be banned automatically.
2.2 Odoo security checklist
# Verificar que el master password de Odoo está configurado y es robusto:
grep admin_passwd /etc/odoo/odoo.conf
# Si está vacío o es "admin", CRÍTICO — cualquiera puede acceder a /web/database/manager
# Verificar que el gestor de BBDD está bloqueado o deshabilitado:
curl -s https://tuodoo.com/web/database/manager | grep -c "Database Manager"
# Si devuelve 1, el gestor está expuesto. Añadir: list_db = False en odoo.conf
# Verificar headers de seguridad en Nginx:
curl -sI https://tuodoo.com | grep -E 'X-Frame|X-Content|Strict|Content-Security'
2.3 Quick audit of custom modules: risk patterns
Custom modules are the most common attack surface in mature Odoo instances. Review with grep:
# Buscar uso de sudo() sin justificación en controladores de API:
grep -r 'sudo()' /opt/odoo/custom_addons/ --include='*.py' -l
# Buscar queries SQL construidas por concatenación (riesgo SQLi):
grep -rn "cr.execute.*%s" /opt/odoo/custom_addons/ --include='*.py' | grep -v 'params'
# Buscar eval() o exec() (riesgo de ejecución de código arbitrario):
grep -rn 'eval(\|exec(' /opt/odoo/custom_addons/ --include='*.py'
# Buscar rutas HTTP sin autenticación declarada:
grep -rn "auth.*public\|auth.*none" /opt/odoo/custom_addons/ --include='*.py'
Block 3: Backups
In half of the audits we carry out, the backup "works" in theory but nobody has ever restored it to verify it. An unverified backup is not a backup: it is wishful thinking.
3.1 Verify that backups exist and are recent
# Verificar backups de PostgreSQL (pg_dump o pgBackRest):
ls -lhrt /var/backups/odoo/ | tail -10
# ¿Cuándo fue el último? ¿Cuántos días de antigüedad tiene?
# Si se usa el backup nativo de Odoo (zip de BBDD + filestore):
ls -lhrt /var/lib/odoo/backups/ | tail -10
# Tamaño del backup vs. tamaño de la BBDD (deben ser comparables):
du -sh /var/backups/odoo/latest_backup.zip
psql -U odoo -c "SELECT pg_size_pretty(pg_database_size('odoo_prod'));"
3.2 Restore test (the step nobody does)
# Restaurar el último backup en una BBDD temporal para verificar integridad:
pg_restore -U postgres -d odoo_test_restore \
--no-owner --no-acl \
/var/backups/odoo/backup_$(date +%Y%m%d).dump
# Verificar que la restauración tiene las tablas esperadas:
psql -U odoo -d odoo_test_restore -c "
SELECT COUNT(*) FROM sale_order;
SELECT COUNT(*) FROM account_move;
SELECT MAX(write_date) FROM res_partner;
"
# Si los conteos son cero o la fecha es antigua, el backup está corrupto
Block 4: Custom modules vs. core — technical debt
One of the biggest long-term risks of an Odoo instance is the accumulation of custom modules that override core behaviour without documentation or tests. Every Odoo version upgrade becomes a months-long project if there are dozens of custom modules with extensive overrides.
4.1 Inventory of installed modules
-- Módulos instalados que NO son de Odoo ni de OCA:
SELECT
name,
author,
installed_version,
state,
category
FROM ir_module_module
WHERE state = 'installed'
AND author NOT ILIKE '%Odoo%'
AND author NOT ILIKE '%OCA%'
ORDER BY name;
-- Módulos con muchos modelos heredados (señal de deuda técnica alta):
SELECT
module,
COUNT(*) AS inherited_models
FROM ir_model_inherit
GROUP BY module
ORDER BY inherited_models DESC
LIMIT 20;
4.2 Classifying technical debt
| Type of debt | Signal | Risk |
|---|---|---|
| Core method overrides without correct inheritance | def write(self): without super() | High — breaks on upgrades |
| Inherited views with fragile xpath | //field[@name='x'] in views that change across versions | Medium |
| Configuration data in Python code | Hardcoded IDs in code (env.ref('module.record_id')) | Medium — environment-dependent |
| Modules without tests | Empty or missing tests/ directory | Medium — silent risk on upgrades |
| Third-party module dependencies without maintenance | Last commit >2 years ago on GitHub | High for migrations |
Block 5: Infrastructure and operating system
- Operating system version: Ubuntu 22.04 LTS or Debian 12 are the recommended platforms in 2026. If the server runs Ubuntu 18.04 or 20.04, the support cycle is ending or has already ended.
- PostgreSQL version: Odoo 16/17 with PostgreSQL 14 or 15 is correct. PostgreSQL 12 or earlier has a less efficient query planner and outdated security extensions.
- Disk space: verify that at least 30% of disk space is free. A full disk on an Odoo production server causes data corruption.
- Swap: must exist and be at least equal to the RAM. If the OOM killer is active in the logs, the Odoo memory configuration (
limit_memory_hard) is incorrectly sized.
# Verificaciones de infraestructura en un comando:
echo "=== DISCO ==="
df -h | grep -v tmpfs
echo "=== MEMORIA ==="
free -h
echo "=== SWAP ==="
swapon --show
echo "=== OOM KILLS (últimas 48h) ==="
dmesg --since -48h | grep -i 'oom\|killed process' | tail -10
echo "=== VERSIÓN PG ==="
psql --version
echo "=== UPTIME ==="
uptime
echo "=== CARGA SISTEMA ==="
sar -u 1 3 2>/dev/null || top -bn1 | head -5
Block 6: Observability — what metrics to monitor
An Odoo instance without observability is a system that only warns you when it is already broken. The minimum metrics that must be monitored are:
| Metric | Alert threshold | How to measure |
|---|---|---|
| TTFB (Time To First Byte) of Odoo | > 2 s on critical paths | Blackbox exporter / curl + time |
| Active workers vs. available | > 80% of workers busy | Endpoint /web/health + Nginx metrics |
| Active PostgreSQL connections | > 80% of max_connections | SELECT count(*) FROM pg_stat_activity |
| Replication lag (if replica exists) | > 30 seconds | pg_stat_replication |
| Blocked queries (locks) | Any query > 5 min blocked | pg_stat_activity WHERE wait_event_type='Lock' |
| Disk space | > 70% used | node_exporter / df |
| Backup success | More than 24 h without a valid backup | Verification script + Telegram alert |
The fastest tool to deploy for Odoo is the ELK stack (Elasticsearch, Logstash, Kibana) with Filebeat collecting Odoo logs, combined with alerts via a Telegram bot. With that setup, any 500 error, slow query or worker failure generates a message to the technical lead's phone in real time.
The deliverable of a technical audit
A professional technical audit does not end with an unprioritised list of findings. The deliverable must have this structure:
- Executive summary (1 page): the 3–5 critical risks requiring immediate action, in non-technical language for management.
- Findings by block: performance, security, backups, code, infrastructure, observability. Each finding with severity (critical / high / medium / low), concrete evidence and a specific recommendation.
- Prioritised action plan: what to do first, what can wait, what is technical debt to be managed in the medium term.
- Effort estimate: approximate hours to resolve each finding, so that the client can plan and budget.
- Metrics baseline: snapshot of the key metrics at the time of the audit (slow queries, disk space, worker configuration) for comparison in future reviews.
How often to audit
The general recommendation depends on system usage:
- Critical instance (> 50 users, real-time operations): quarterly metrics review + full annual audit.
- Medium instance (10–50 users, office operations): full audit every 6 months.
- Small instance (< 10 users): annual audit, plus always before an Odoo version upgrade.
- After any serious incident (outage, data corruption, security incident): immediate audit.
What an audit cannot do on its own
An audit is a snapshot of the current state. Without an executed remediation plan, the findings pile up in a document that nobody re-reads. The real value lies in the combination of diagnosis + action plan + execution. For critical instances, the recommendation is to retain a monthly technical retainer that includes the proactive resolution of recurring findings: query optimisation, update management, backup verification and continuous monitoring.