NUC Security Audit: ModSecurity Removed, 500 MB Recovered

โก TL;DR
A security stack audit on the homelab NUC reveals redundant double WAF inspection: ModSecurity + OWASP CRS load 11,872 rules into memory despite SecRuleEngine Off, running in parallel with CrowdSec AppSec which already covers the same surface. After removing the ModSecurity nginx module and five other targeted fixes, nginx drops from ~520 MB to ~27 MB PSS. Same security, memory footprint divided by 20.
๐๏ธ Architecture Before the Audit
The security stack had six stacked layers:
Internet
โ
Cloudflare WAF (edge WAF + DDoS + CrowdSec auto-ban)
โ
nginx (CrowdSec Lua bouncer)
โ
ModSecurity + OWASP CRS โโโ double inspection
CrowdSec AppSec โโโ double inspection
โ
CrowdSec agent v1.7.8 (scenarios: http-cve, nginx, linux, sshd)
โ
Bouncers: firewall-bouncer (nftables) + nginx-bouncer (Lua) + cf-sync
โ
Backend (Hugo VM 192.168.122.69 / PHP-FPM)
The core issue: ModSecurity and CrowdSec AppSec each inspect every request independently. The CRS loads its regex into memory even when SecRuleEngine Off is set โ the module is loaded, rules compiled, nothing is inspected, but RAM is consumed.
Initial memory footprint (smem PSS)
| Component | PSS |
|---|---|
| nginx (master + 4 workers) | ~520 MB |
| CrowdSec agent | ~170 MB |
| firewall-bouncer | ~30 MB |
| Total security stack | ~720 MB |
๐ 6 Issues Identified
1. ModSecurity / AppSec double inspection
ModSecurity was configured with SecRuleEngine Off โ functionally inactive โ but the nginx module was still loading 11,872 OWASP CRS rules on every startup. CrowdSec AppSec (appsec-generic-rules + appsec-virtual-patching) covers the same surface with community-maintained rules.
Decision: ModSecurity removed, AppSec kept.
2. APPSEC_FAILURE_ACTION=allow
The nginx bouncer config had APPSEC_FAILURE_ACTION=allow โ if AppSec fails to respond within its timeout (timeout, oversized body), the request passes through. Changed to passthrough: on AppSec failure, the request goes to the backend without blocking but also without inspection. This bug was causing the Hugo 502s: AppSec was returning request body exceeded limit and the allow logic created an inconsistent state.
3. mcp-hugo.access.log parsed with the wrong parser
The Hugo MCP JSON log was being read by the standard nginx parser (combined format). 126 out of 132 entries showed up as “unparsed” in CrowdSec metrics. Fix: dedicated acquisition with the mcp-oauth parser.
4. http-sensitive-files duplicate โ false alarm
The warning multiple scenarios named crowdsecurity/http-sensitive-files was misleading: a local override (capacity 2, leakspeed 60s) intentionally replaces the hub scenario (capacity 4, leakspeed 5s) to reduce false positives. One active scenario, nothing to fix.
5. Oversized nginx proxy_buffers
Three vhosts (radarr, sonarr, plex) used proxy_buffers 32 512k or 16 256k โ up to 16 MB of buffer per peak connection. For near-zero homelab traffic, this is direct PSS waste.
6. nginx-bouncer: 0 non-empty decisions
Metrics showed crowdsec-nginx-bouncer: Empty answers = 11 / Non-empty = 0. This is normal behavior: banned IPs are blocked by Cloudflare before reaching nginx. The bouncer queries the LAPI for each legitimate visitor โ empty response is correct. No malfunction.
๐ง Fixes Applied
Fix 1 โ ModSecurity removal
# Comment out module loading in nginx.conf
#load_module /etc/nginx/modules-enabled/ngx_http_modsecurity_module.so;
# Disable in all vhosts (sed across sites-available/)
#modsecurity on; # DISABLED โ using CrowdSec AppSec only
#modsecurity_rules_file ...;Applied to 10 active vhosts via a single sed pass. nginx -t + systemctl reload nginx.
Fix 2 โ APPSEC_FAILURE_ACTION
# /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf
APPSEC_FAILURE_ACTION=passthrough # was: allowFix 3 โ Dedicated acquisition for mcp-hugo
# /etc/crowdsec/acquis.d/mcp-hugo.yaml
filenames:
- /var/log/nginx/mcp-hugo.access.log
labels:
type: mcp-oauthFix 5 โ Reducing proxy_buffers
# Before (radarr, sonarr)
proxy_buffers 32 512k;
proxy_buffer_size 512k;
# After
proxy_buffers 8 64k;
proxy_buffer_size 16k;
proxy_busy_buffers_size 128k;๐ Results
nginx memory after full restart
nginx: master โ PSS 2.3 MB
nginx: worker ร3 โ PSS 6.4 MB each
nginx: worker ร1 โ PSS 6.9 MB
Total โ PSS ~27 MBBefore / After comparison
| Component | Before | After |
|---|---|---|
| nginx (total PSS) | ~520 MB | ~27 MB |
| CrowdSec agent | ~170 MB | ~170 MB |
| firewall-bouncer | ~30 MB | ~30 MB |
| Total security stack | ~720 MB | ~227 MB |
-493 MB freed. The gain comes entirely from unloading the ModSecurity module and reducing proxy_buffers.
Security: zero regression
| Layer | Before | After |
|---|---|---|
| Edge WAF | Cloudflare โ | Cloudflare โ |
| Application inspection | ModSec (off) + AppSec | AppSec only โ |
| Behavioral detection | CrowdSec โ | CrowdSec โ |
| Network ban | firewall-bouncer โ | firewall-bouncer โ |
| Cloudflare auto-ban | cf-sync โ | cf-sync โ |
๐ง The Lesson
The stack was described as “over-secured” โ multiple WAF layers stacked, each added at a different time for a valid reason. The problem: a functionally disabled layer is still expensive if the module is loaded. ModSecurity with SecRuleEngine Off consumed as much RAM as in active mode โ 11,872 regex rules compiled at nginx startup, whether requests were inspected or not.
The rule to remember: an inactive security layer is not free if the module is in memory.
๐ Final Stack
Internet
โ
Cloudflare WAF + DDoS
โ
nginx (Lua bouncer โ stream mode)
โ
CrowdSec AppSec (appsec-generic-rules + appsec-virtual-patching)
โ
CrowdSec agent v1.7.8
โ
firewall-bouncer (nftables) / nginx-bouncer / cf-sync
โ
BackendFour active layers, zero redundancy, ~500 MB of RAM recovered.