Skip to content

Set Up Per-Site WAF Rules with ModSecurity and Nginx

Stop blanket-blocking legit requests on shared hosting. This guide shows how to apply different ModSecurity rule sets per site using Nginx and separate config files.

Written by AISali·August 15, 2026·4 min read

The Problem with One-Size-Fits-All WAFs#

On a shared server, a single ModSecurity configuration creates friction. The WordPress site on site-a.com needs wp-login.php accessible, but the custom PHP app on site-b.com has a form that triggers the same rule. You end up disabling rules globally, weakening protection for everyone.

The fix is per-site ModSecurity rules. Instead of one modsecurity.conf for the entire server, each virtual host gets its own rule set, tuned to the application it runs. This guide walks through the exact steps to set this up with Nginx and ModSecurity v3 (the modsecurity-nginx connector).

Prerequisites#

Before you start, make sure you have:

  • Nginx compiled with the ModSecurity dynamic module (ngx_http_modsecurity_module.so)
  • ModSecurity v3 (libmodsecurity) installed
  • A base rule set like OWASP Core Rule Set (CRS) v4.x
  • Separate Nginx server blocks for each site (standard for shared hosting)

If you haven't compiled ModSecurity for Nginx yet, the ModSecurity v3 Nginx connector repo has build instructions. Most distros now ship libmodsecurity3 in their repos.

Step 1: Create a Global ModSecurity Base Config#

This file holds the engine settings that apply everywhere. Create /etc/nginx/modsec/main.conf:

conf
# /etc/nginx/modsec/main.conf

SecRuleEngine On
SecRequestBodyAccess On
SecResponseBodyAccess Off
SecAuditLogType Serial
SecAuditLog /var/log/modsec_audit.log

# Include the CRS setup (tune paranoia level here)
Include /etc/nginx/modsec/crs/crs-setup.conf
Include /etc/nginx/modsec/crs/rules/*.conf

This is your baseline. The OWASP CRS rules load here, and every site inherits them unless you override per-site.

Step 2: Create Per-Site Rule Override Directories#

Create a directory for each site's custom rules:

bash
mkdir -p /etc/nginx/modsec/sites/site-a.com
mkdir -p /etc/nginx/modsec/sites/site-b.com

Inside each, create a custom-rules.conf file. For example, for a WordPress site:

conf
# /etc/nginx/modsec/sites/site-a.com/custom-rules.conf

# Allow XML-RPC (needed by Jetpack, some mobile apps)
SecRule REQUEST_URI "@beginsWith /xmlrpc.php" \
    "id:10001,phase:1,pass,nolog,ctl:ruleRemoveById=920350"

# Allow wp-admin POST requests without triggering SQLi rules
SecRule REQUEST_URI "@beginsWith /wp-admin/admin-ajax.php" \
    "id:10002,phase:1,pass,nolog,ctl:ruleRemoveById=942100,ctl:ruleRemoveById=942200"

For a custom PHP app that sends JSON payloads:

conf
# /etc/nginx/modsec/sites/site-b.com/custom-rules.conf

# Don't inspect JSON bodies for this app (false positives on nested objects)
SecRule REQUEST_URI "@beginsWith /api/" \
    "id:20001,phase:1,pass,nolog,ctl:requestBodyProcessor=URLENCODED"

# Whitelist a specific POST endpoint
SecRule REQUEST_URI "@beginsWith /api/webhook" \
    "id:20002,phase:1,pass,nolog,ctl:ruleRemoveById=920350,ctl:ruleRemoveById=942100"

The key is using ctl:ruleRemoveById to selectively disable CRS rules per site, rather than disabling them globally.

Step 3: Create a Per-Site ModSecurity Include File#

Each site needs a small file that loads the global config and then its own rules. Create /etc/nginx/modsec/sites/site-a.com/modsec.conf:

conf
# /etc/nginx/modsec/sites/site-a.com/modsec.conf

Include /etc/nginx/modsec/main.conf
Include /etc/nginx/modsec/sites/site-a.com/custom-rules.conf

Repeat for every site. This is the single file Nginx will reference.

Step 4: Wire It Into Nginx Server Blocks#

In each site's Nginx server block, point to the site-specific include:

nginx
server {
    listen 80;
    server_name site-a.com www.site-a.com;
    root /var/www/site-a.com/public;

    modsecurity on;
    modsecurity_rules_file /etc/nginx/modsec/sites/site-a.com/modsec.conf;

    # ... rest of config
}

Do the same for site-b.com, pointing to its own modsec.conf.

Important: modsecurity_rules_file is an absolute path. Don't use relative paths — Nginx resolves them from its working directory, not the config file's location.

Step 5: Test and Reload#

Validate the Nginx config before reloading:

bash
nginx -t

If it passes, reload:

bash
systemctl reload nginx

Test that rules are firing per-site. Send a known CRS trigger (SQL injection test string) to both sites:

bash
curl -I "https://site-a.com/?id=1%20AND%201=1"
curl -I "https://site-b.com/?id=1%20AND%201=1"

Check /var/log/modsec_audit.log to confirm the rule IDs and which site triggered them. If you've whitelisted a rule for one site, it should still block on the other.

Step 6: Add Per-Site Audit Logging (Optional)#

If you want separate audit logs per site (useful when debugging customer complaints), add this to each site's custom-rules.conf:

conf
SecAuditLog /var/log/modsec/site-a.com.log
SecAuditLogType Serial

This overrides the global SecAuditLog for that site only.

Automating This for New Accounts#

If you're running a hosting platform, you'll want to automate this on account creation. The minimum viable automation:

  1. Create the site directory and custom-rules.conf from a template based on detected application type (WordPress, Laravel, generic PHP)
  2. Generate the modsec.conf include file
  3. Add the modsecurity_rules_file directive to the Nginx vhost
  4. Reload Nginx

Platforms like Salieno Core handle this as part of account provisioning — each site gets its own ModSecurity profile without manual intervention. But even a shell script triggered by your WHMCS provisioning hook can do the job.

Tuning Tips#

  • Start with CRS Paranoia Level 1. PL2 and above generate significantly more false positives on shared hosting.
  • Use `SecRuleRemoveById` in per-site files, not in the global CRS config. This keeps your base config clean.
  • Monitor `/var/log/modsec_audit.log` weekly. Look for repeated false positives on specific rule IDs and fix them per-site.
  • Never set `SecRuleEngine Off` in a per-site file. If a customer's app breaks, fix the specific rule, don't kill the WAF.

The Payoff#

Per-site ModSecurity rules mean you can run aggressive protection on sites that need it (ecommerce, login-heavy apps) while giving more breathing room to static sites or well-secured CMS installs. Your customers stop complaining about blocked requests, and you stop weakening the WAF for everyone to accommodate one noisy site. It's more work upfront, but it's the difference between a WAF that stays on and one that gets quietly disabled in production.

Share

0 comments

Loading comments…

More from the blog

Set Up Per-Site WAF Rules with ModSecurity and Nginx · Salieno Blog