Back to tutorials
Tutorial

Web Application Firewall Tutorial (2026): Set Up ModSecurity + OWASP CRS on Apache or Nginx to Protect WordPress on a VPS

Web application firewall tutorial for 2026: install ModSecurity + OWASP CRS on Apache/Nginx to block WordPress attacks safely.

By Anurag Singh
Updated on Sep 18, 2026
Category: Tutorial
Share article
Web Application Firewall Tutorial (2026): Set Up ModSecurity + OWASP CRS on Apache or Nginx to Protect WordPress on a VPS

Most WordPress compromises on VPS hosting don’t start with “someone guessed your SSH password.” They usually start with a vulnerable plugin endpoint that gets hammered thousands of times per hour.

A WAF won’t replace patching. It can still block a large slice of exploit traffic before it reaches PHP, without changing your application code.

This web application firewall tutorial shows how to install ModSecurity v3 with the OWASP Core Rule Set (CRS) on a Linux VPS.

Then you’ll tune it so WordPress and WooCommerce keep working.

You’ll end with clear audit logs, coverage for common attack classes (SQLi, XSS, RCE patterns), and a safe process for tightening enforcement over time.

What you’ll build (and what you need first)

  • Option A (Apache): Apache + ModSecurity (dynamic module) + OWASP CRS
  • Option B (Nginx): Nginx + ModSecurity v3 connector + OWASP CRS
  • Mode: Start in DetectionOnly, then move to On after tuning
  • Logging: Human-readable audit logs and a quick way to map “blocked” to “what rule”

Assumptions: You have root (or sudo) on Ubuntu 24.04 LTS / Debian 12 / AlmaLinux 9/10 / Rocky 9/10. Your site already serves HTTPS (Let’s Encrypt or a paid cert).

If HTTPS is still pending, fix that first using this guide: VPS SSL setup guide with safe auto-renew.

If you want a stable base for security changes like this, use a HostMyCode VPS so you control Nginx/Apache, logs, and rule files.

Quick decision: Apache or Nginx for ModSecurity?

StackWhy you’d pick itTradeoffs
Apache + ModSecurityFastest to deploy; packages are mature; simplest integrationPer-vhost tuning can get noisy on multi-site boxes
Nginx + ModSecurity v3Good if you’re already standardized on Nginx and want WAF at the edgeOften requires compiling or vendor packages; more moving parts

If you run several WordPress sites and want the least friction, Apache packaging is usually smoother.

If your stack is already Nginx, stick with the Nginx route. Plan for staging and disciplined log review.

Step 1 — Baseline your VPS and WordPress before adding a WAF

Do this first. It prevents “the WAF broke my site” guesswork later.

It also helps you separate false positives from existing permission, update, or plugin issues.

  1. Confirm your site is healthy (no existing 500s):

    curl -I https://example.com/
    curl -I https://example.com/wp-login.php
  2. Capture current error context so you can compare later.

    If you’re unsure where to look, follow: VPS log analysis tutorial.

  3. Make sure WordPress can update cleanly. A WAF won’t fix broken file ownership or stuck updates.

    Use: WP-CLI troubleshooting tutorial.

  4. Take a rollback point. At minimum, snapshot the VPS or back up configs before you touch web server modules.

    If you want a measured approach (RPO/RTO, encrypted offsite copies), follow: VPS backup strategy tutorial.

Step 2 — Install ModSecurity on Apache (Ubuntu/Debian)

This is the simplest path on most VPS setups. Install the module, enable it, and confirm Apache reads the config.

Install packages

sudo apt update
sudo apt install -y apache2 libapache2-mod-security2 git

Enable ModSecurity and load the recommended config

sudo a2enmod security2
sudo cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf

Switch to DetectionOnly for first boot

Edit /etc/modsecurity/modsecurity.conf and set:

SecRuleEngine DetectionOnly

Then restart Apache:

sudo systemctl restart apache2
sudo systemctl status apache2 --no-pager

Verify it’s loaded

sudo apachectl -M | grep -i security

You should see security2_module.

If Apache fails to start, check:

sudo journalctl -u apache2 -n 80 --no-pager

Step 3 — Install ModSecurity on Apache (AlmaLinux/Rocky)

On RHEL-family distros, the package names and defaults differ a bit.

sudo dnf install -y httpd mod_security mod_security_crs git
sudo systemctl enable --now httpd

On some builds, CRS ships as a separate package. It may also lag behind the current OWASP CRS.

If you want the newest CRS (recommended in 2026), use the Git install method below. Then point ModSecurity at that copy.

Step 4 — Add OWASP CRS (recommended) and wire it in

CRS is where the real coverage comes from. ModSecurity is the engine. CRS is the rule set.

Install CRS somewhere you control. Keep it versioned. Update it on purpose—not accidentally.

Install CRS from Git

sudo mkdir -p /etc/modsecurity
cd /etc/modsecurity
sudo git clone https://github.com/coreruleset/coreruleset.git owasp-crs

Create the CRS setup file

cd /etc/modsecurity/owasp-crs
sudo cp crs-setup.conf.example crs-setup.conf

Include CRS from your ModSecurity include file

On Ubuntu/Debian with libapache2-mod-security2, the include file is commonly:

/etc/modsecurity/modsecurity.conf
/etc/apache2/mods-enabled/security2.conf

Edit /etc/apache2/mods-enabled/security2.conf and add these lines near the end:

IncludeOptional /etc/modsecurity/owasp-crs/crs-setup.conf
IncludeOptional /etc/modsecurity/owasp-crs/rules/*.conf

Then reload:

sudo apachectl configtest
sudo systemctl reload apache2

Confirm CRS rules are being parsed

Right after the reload, check the error log for CRS load messages:

sudo tail -n 200 /var/log/apache2/error.log

If nothing stands out, don’t guess. Trigger a test request in the next step, and confirm the audit log records it.

Step 5 — Configure audit logging you can actually use

The usual failure mode is simple. Something gets blocked, and you can’t tell which rule did it.

Get logging right first. Tighten enforcement second.

In /etc/modsecurity/modsecurity.conf, set these basics:

SecAuditEngine RelevantOnly
SecAuditLog /var/log/modsecurity/audit.log
SecAuditLogParts ABIJDEFHZ
SecAuditLogType Serial

Create the log path and permissions:

sudo mkdir -p /var/log/modsecurity
sudo touch /var/log/modsecurity/audit.log
sudo chown -R www-data:adm /var/log/modsecurity || true
sudo chmod 640 /var/log/modsecurity/audit.log

On RHEL-family systems, replace www-data with apache:

sudo chown -R apache:adm /var/log/modsecurity
sudo chmod 640 /var/log/modsecurity/audit.log

Reload Apache:

sudo systemctl reload apache2 || sudo systemctl reload httpd

Step 6 — Run a controlled “attack string” test

Even in DetectionOnly, CRS should log the match.

A basic SQLi-style string usually triggers a rule. Run this from your workstation:

curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/?id=1%20or%201=1"

You’ll likely still get 200 because you’re in DetectionOnly.

Now search the audit log for the matching transaction:

sudo grep -n "id=1" -n /var/log/modsecurity/audit.log | tail -n 5

If the audit log stays empty, CRS isn’t included or the log path/permissions are wrong.

Fix that before you go any further.

Step 7 — Tune CRS for WordPress and WooCommerce (without turning it into a no-op)

WordPress admin endpoints, REST API calls, and WooCommerce checkout fields can trip rules written for generic web apps.

The fix is not “turn off CRS.” The fix is narrow exclusions tied to a specific path, parameter, and rule ID.

7.1 Create a custom exclusions file

Create a file that loads after CRS rules. For Apache, a clean pattern is:

sudo mkdir -p /etc/modsecurity/custom
sudo nano /etc/modsecurity/custom/zz-wordpress-exclusions.conf

Then include it after CRS in security2.conf:

IncludeOptional /etc/modsecurity/custom/*.conf

Reload Apache after edits.

7.2 Identify false positives by rule ID

When something fails in blocking mode, start with the rule ID.

In audit logs, search for id ":

sudo grep -n "\[id \"" /var/log/modsecurity/audit.log | tail -n 20

You’ll see IDs like 942100 (SQL injection family) or 941100 (XSS family).

For each breakage, capture:

  • rule ID
  • request path (example: /wp-admin/admin-ajax.php)
  • parameter name (example: search, billing_address_1)

7.3 Add minimal, targeted exceptions

Keep exceptions as narrow as you can.

The examples below are templates. Swap in the rule IDs you actually see in your logs.

Example A: Exclude a single parameter on a single path (best case):

SecRule REQUEST_URI "@beginsWith /wp-admin/admin-ajax.php" \
  "id:1001001,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942100;ARGS:query"

Example B: Exclude WooCommerce checkout fields from one rule family (use sparingly):

SecRule REQUEST_URI "@beginsWith /?wc-ajax=checkout" \
  "id:1001002,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941100;ARGS:billing_address_1"

Example C: Allow WordPress REST API to pass a specific rule (if it’s tripping on JSON payloads):

SecRule REQUEST_URI "@beginsWith /wp-json/" \
  "id:1001003,phase:1,pass,nolog,ctl:ruleRemoveById=920274"

Prefer ruleRemoveTargetById over ruleRemoveById.

Dropping an entire rule creates a much larger gap.

7.4 Don’t forget login and XML-RPC decisions

If you don’t use XML-RPC, block it at the web server layer.

That cuts noise in the WAF logs and removes a common attack surface:

<Location "/xmlrpc.php">
  Require all denied
</Location>

On Nginx, you’d use a location block returning 403.

If you still need XML-RPC for a legacy integration, keep it enabled. Rate-limit it and monitor it closely.

Step 8 — Switch from DetectionOnly to blocking (the safe way)

Going straight to full blocking on a production store is how checkout breaks.

Treat the switch as a controlled change.

Phase 1: Turn on blocking during a low-traffic window

Edit /etc/modsecurity/modsecurity.conf:

SecRuleEngine On

Reload Apache and immediately test the flows that matter:

  • Home page
  • wp-admin login
  • Publishing a post
  • WooCommerce add-to-cart and checkout
  • Contact form submission

Phase 2: Watch logs for 24–48 hours, then tighten gradually

Spend time in the audit log. Look for repeated blocks from the same IPs.

Also watch for bursts against /wp-login.php.

Review suspicious query strings and abused plugin endpoints.

If you also need rate limiting for brute force, keep it separate from WAF logic.

Handle it at the web server layer for cleaner failure modes.

This guide can help for Nginx-based sites: Nginx rate limiting tutorial.

Step 9 — Nginx path (ModSecurity v3 connector): what changes

If you run Nginx, ModSecurity isn’t a native module the way it is on Apache.

Most deployments in 2026 use ModSecurity v3 (libmodsecurity) plus the Nginx connector.

The install details vary by distro and repository policy.

Use this section as an operational checklist rather than a copy-paste recipe:

  1. Decide packaging: vendor packages (if available) or compile libmodsecurity + connector.
  2. Place configs: keep ModSecurity config in /etc/modsecurity/ and CRS under /etc/modsecurity/owasp-crs/.
  3. Enable in Nginx: in your server block or http context, set the ModSecurity directives (commonly modsecurity on; and modsecurity_rules_file /etc/modsecurity/modsecurity.conf; depending on connector build).
  4. Log review: confirm audit logging works before turning on blocking.

If your WordPress stack is Nginx + PHP-FPM, keep the WAF config per-site (server block) rather than global, at least for the first week.

Rollbacks stay clean, and you won’t take every site down at once.

Step 10 — Common breakages and quick fixes

  • Admin-ajax requests fail (random UI features stop working)

    Check for blocks on /wp-admin/admin-ajax.php.

    Add a targeted ruleRemoveTargetById for the specific parameter, not a blanket exception for the whole endpoint.

  • REST API returns 403

    Pull the exact rule ID and the JSON key from the audit entry.

    In many cases, excluding one JSON field from one rule ID is enough.

  • Contact forms or checkout fail

    Forms often trigger XSS rules because real user text contains punctuation, URLs, and odd formatting.

    Start by excluding only the text-area parameter from the specific rule that fires.

  • AutoSSL/Let’s Encrypt validation fails

    WAF rules can interfere if you use custom challenge locations.

    If you see blocks on /.well-known/acme-challenge/, explicitly allow that path and avoid rewriting it.

Step 11 — Operational checklist for ongoing WAF maintenance

Once blocking is enabled, treat CRS updates like any other production change.

Keep them planned, reviewable, and reversible.

  • Pin a CRS version in Git; don’t auto-pull to main on cron.
  • Review audit logs weekly for new false positives and recurring attack paths.
  • Rotate logs so /var/log doesn’t quietly fill up. If you need a clean logrotate pattern, use: Logrotate tutorial.
  • Patch WordPress and plugins on a schedule. A WAF buys time; it doesn’t remove the vulnerability.
  • Keep a rollback plan: know how to flip SecRuleEngine back to DetectionOnly in under 60 seconds.

Where HostMyCode fits (so this stays manageable)

WAF tuning gets much easier when you control the full stack.

It also helps when you can read logs without limits.

That’s why teams move revenue-critical WordPress sites off shared hosting once uptime matters.

If you’re rolling out ModSecurity + OWASP CRS for WordPress, use a VPS you can snapshot, monitor, and roll back quickly. Start with a HostMyCode VPS, or choose managed VPS hosting if you want help with updates, security baselines, and incident triage.

FAQ

Will ModSecurity slow down my WordPress site?

Yes. Every request gets inspected, so there’s always some overhead.

On most VPS setups, the bigger performance hit comes from noisy logging and overly broad exceptions.

Use RelevantOnly audit logging, then tune exclusions so admin and checkout flows don’t repeatedly trigger heavyweight rules.

Should I run the WAF in DetectionOnly forever?

No. DetectionOnly is for learning what would have been blocked, not stopping exploitation.

A practical pattern is 24–72 hours in DetectionOnly, then switch to blocking and fix false positives with targeted exclusions.

Can I use this on cPanel/WHM servers?

Yes, but cPanel systems may already include ModSecurity management.

If you’re using WHM’s ModSecurity tools, make changes in the panel so updates don’t overwrite your work. For baseline cPanel security, follow: cPanel hardening tutorial.

What’s the quickest way to un-break the site if I lock myself out?

Switch SecRuleEngine back to DetectionOnly, reload the web server, then review the latest audit log entries to find the rule ID responsible for the block.

Summary: a WAF that blocks attacks without breaking checkout

ModSecurity + OWASP CRS can cut down real exploit traffic fast, especially on WordPress sites that can’t patch instantly.

Install the engine, load CRS, verify audit logging, and only then move to blocking.

As you tune, use tight, specific exceptions based on real rule IDs.

If you want a stable platform for this work—snapshots, full root access, predictable performance—run it on a HostMyCode VPS. If you’d rather hand off baseline hardening and ongoing upkeep, managed VPS hosting is the cleaner option.