2026年11月12日
2026年11月12日
Set Up an Apache WAF with mod_security on Ubuntu and AlmaLinux
This article is a localized edition of the Japanese original. Read the Japanese original
ModSecurity (mod_security) is a WAF (web application firewall) module that you build into Apache. ModSecurity itself is the inspection engine; "rules" decide what counts as an attack. It is usually combined with OWASP CRS (Core Rule Set), a general-purpose rule set that detects common attacks such as SQL injection and cross-site scripting.
This article explains how to install ModSecurity and CRS from OS packages on Apache on a VPS or dedicated server. The steps were tested in Ubuntu 24.04 and AlmaLinux 9 containers as of October 2026.
Versions and assumptions as of October 2026
| Ubuntu 24.04 | AlmaLinux 9 | |
|---|---|---|
| ModSecurity package | libapache2-mod-security2 (2.9.7) |
mod_security (2.9.6) |
| CRS package | modsecurity-crs (3.3.5) |
mod_security_crs (3.3.5) |
| Behavior right after install | Rules don't run (you need to create a configuration file) | Runs in blocking mode |
- ModSecurity moved from Trustwave to OWASP in 2024 and is now maintained as an OWASP project. The version that runs as an Apache module is the v2 line
- The latest CRS is the 4.x line (v4.30.0 as of October 2026), but both OS packages ship 3.3.5. This article assumes the packaged 3.3.5. CRS 4 changed things such as how WordPress exclusions work, so if you install 4.x yourself, follow the official CRS documentation
- On shared hosting, a ModSecurity-like feature is often offered as a "WAF" setting in the control panel, and you usually can't edit the rules yourself. The steps in this article are for servers where you have root access
Installing on Ubuntu 24.04
1. Install the packages
sudo apt update
sudo apt install apache2 libapache2-mod-security2 modsecurity-crs
Installing enables the security2 module. Check that it is loaded.
sudo apache2ctl -M | grep security2
# If you see security2_module (shared), it is loaded
2. Create the configuration file
On Ubuntu, only a sample ModSecurity configuration file (modsecurity.conf-recommended) is installed. Copy it to create modsecurity.conf.
sudo cp /etc/modsecurity/modsecurity.conf-recommended /etc/modsecurity/modsecurity.conf
Right after copying, modsecurity.conf is set to SecRuleEngine DetectionOnly (detect and log, but don't block). Use it this way at first.
The files are loaded through /etc/apache2/mods-enabled/security2.conf, which contains:
<IfModule security2_module>
SecDataDir /var/cache/modsecurity
IncludeOptional /etc/modsecurity/*.conf
IncludeOptional /usr/share/modsecurity-crs/*.load
</IfModule>
This loads the .conf files in /etc/modsecurity/ (including the modsecurity.conf you created) and CRS (via /usr/share/modsecurity-crs/owasp-crs.load, which loads the settings in /etc/modsecurity/crs/crs-setup.conf and the rules in /usr/share/modsecurity-crs/rules/*.conf).
3. Check the configuration and apply it
sudo apache2ctl configtest
sudo systemctl reload apache2
Installing on AlmaLinux 9
sudo dnf install httpd mod_security mod_security_crs
sudo apachectl -M | grep security2
AlmaLinux uses a different file layout from Ubuntu.
| What | Location |
|---|---|
| ModSecurity settings | /etc/httpd/conf.d/mod_security.conf |
| Module loading | /etc/httpd/conf.modules.d/10-mod_security.conf |
| CRS settings | /etc/httpd/modsecurity.d/crs-setup.conf |
| CRS rules | /etc/httpd/modsecurity.d/activated_rules/ |
| Exclusion files loaded before/after CRS | /etc/httpd/modsecurity.d/activated_rules/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf, RESPONSE-999-EXCLUSION-RULES-AFTER-CRS.conf |
| Where to put your own rules | /etc/httpd/modsecurity.d/local_rules/ |
| Audit log | /var/log/httpd/modsec_audit.log |
In the AlmaLinux package, mod_security.conf is set to SecRuleEngine On (blocking mode) from the start. If you install it on a live site, switch to DetectionOnly first and then restart Apache, so false positives don't block legitimate traffic.
sudo sed -i 's/SecRuleEngine On/SecRuleEngine DetectionOnly/' /etc/httpd/conf.d/mod_security.conf
grep -n "SecRuleEngine" /etc/httpd/conf.d/mod_security.conf
sudo apachectl configtest
sudo systemctl restart httpd
The rest of this article uses Ubuntu paths. On AlmaLinux, substitute the locations in the table above.
Test in detection-only mode
With detection only (DetectionOnly) enabled, send a request that looks like an attack and check that it is logged.
# A request that looks like an attack (a command execution attempt)
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost/?exec=/bin/bash'
In detection-only mode, the response is normal, such as 200. Apache's error log (/var/log/apache2/error.log on Ubuntu, /var/log/httpd/error_log on AlmaLinux) records a line like this:
ModSecurity: Warning. ... [id "932160"] [msg "Remote Command Execution: Unix Shell Code Found"] ...
[id "…"] is the number of the rule that matched, and [msg "…"] describes it. To see which rules match most often, count them like this:
sudo grep ModSecurity /var/log/apache2/error.log | grep -o '\[id "[0-9]*"\]' | sort | uniq -c | sort -rn | head
Full request details go to the audit log (/var/log/apache2/modsec_audit.log). With the default setting (SecAuditEngine RelevantOnly), it records requests that matched a rule and requests that ended in a 4xx (other than 404) or 5xx error.
Run like this for a few days to a week, and check that normal operations (saving posts, changing plugin settings, submitting contact forms and so on) don't trigger rules.
Switch to blocking mode
Once you've dealt with the false positives, set SecRuleEngine to On.
sudo sed -i 's/^SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/modsecurity/modsecurity.conf
sudo apache2ctl configtest
sudo systemctl reload apache2
# Confirm that a request that looks like an attack now gets a 403
curl -s -o /dev/null -w '%{http_code}\n' 'http://localhost/?exec=/bin/bash'
CRS 3.3 uses anomaly scoring: it adds up the scores of the rules that match and blocks the request when the total exceeds a threshold. When a request is blocked, the error log records a line with [id "949110"] (Inbound Anomaly Score Exceeded), and the lines before it record the individual rules that added to the score. When investigating false positives, look at those individual rule numbers, not 949110.
Enable the WordPress exclusions
CRS 3.3 includes exclusion rules that avoid false positives common in the WordPress admin and elsewhere, but they are disabled by default. /etc/modsecurity/crs/crs-setup.conf contains this commented-out setting (id:900130):
#SecAction \
# "id:900130,\
# phase:1,\
# nolog,\
# pass,\
# t:none,\
# setvar:tx.crs_exclusions_cpanel=1,\
# setvar:tx.crs_exclusions_drupal=1,\
# setvar:tx.crs_exclusions_dokuwiki=1,\
# setvar:tx.crs_exclusions_nextcloud=1,\
# setvar:tx.crs_exclusions_wordpress=1,\
# setvar:tx.crs_exclusions_xenforo=1"
To enable only WordPress, uncomment it and keep only the WordPress line.
SecAction \
"id:900130,\
phase:1,\
nolog,\
pass,\
t:none,\
setvar:tx.crs_exclusions_wordpress=1"
In a test environment, sending post content containing <script> to the post save endpoint (/wp-admin/post.php) was blocked with a 403 without this setting, and was not blocked with it enabled.
Write your own rules
Rules take the form SecRule VARIABLES OPERATOR "ACTIONS". Give your own rules numbers (id) that don't overlap with CRS or others. On Ubuntu, any .conf file you create in /etc/modsecurity/ is loaded (on AlmaLinux, use /etc/httpd/modsecurity.d/local_rules/).
Example: Restrict access to wp-login.php by IP address
# /etc/modsecurity/zz-local-rules.conf
SecRule REQUEST_FILENAME "@endsWith /wp-login.php" \
"id:1000001,phase:1,deny,status:403,log,msg:'wp-login.php access from non-allowed IP',chain"
SecRule REMOTE_ADDR "!@ipMatch 203.0.113.10,198.51.100.0/24"
- The first line checks whether the requested file ends with
/wp-login.php, andchainlinks it to the condition on the next line - The second line checks whether the client IP address is not in the allowed list. If both match,
denyreturns a 403 - Addresses such as
203.0.113.10are examples. Replace them with your own IP addresses phase:1inspects the request once its headers have been received- Behind a CDN such as Cloudflare or a load balancer,
REMOTE_ADDRis the IP address of the proxy, not the visitor, so this rule won't restrict access correctly
When we tried the rule containing ctl:requestBodyProcessor.absentCheck from an earlier version of this article, apache2ctl configtest failed with "Error parsing actions: Missing ctl value", and Apache could not start. After writing a rule, check the syntax with apache2ctl configtest before applying it.
Example: Exclude a specific rule (handling false positives)
If a false positive happens only on a specific URL, disable that rule only for that URL. Find the rule number in the [id "…"] part of the error log.
# /etc/apache2/conf-available/modsec-exclusions.conf
<LocationMatch "^/wp-admin/admin-ajax\.php$">
SecRuleRemoveById 942100
</LocationMatch>
sudo a2enconf modsec-exclusions
sudo apache2ctl configtest
sudo systemctl reload apache2
SecRuleRemoveById must come after the target rule has been loaded. Putting it in the Apache configuration (conf-available) as above loads it after CRS.
To exclude only a specific input from inspection rather than a whole rule, write a rule using ctl in the exclusion file loaded before CRS, /etc/modsecurity/crs/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf (on AlmaLinux, the file with the same name in activated_rules). The following example excludes only the content field sent when saving a post from the XSS rules (tag attack-xss).
SecRule REQUEST_FILENAME "@endsWith /wp-admin/post.php" \
"id:1000010,phase:1,pass,nolog,ctl:ruleRemoveTargetByTag=attack-xss;ARGS:content"
The broader an exclusion, the weaker your protection. Narrow it down as much as possible to which URL, which input and which rule. How to investigate false positives with WordPress is covered in more detail in How to set up a WAF with Apache mod_security (ModSecurity): Installing OWASP CRS and handling false positives.
Operational notes
- Performance: Every request is inspected, so server load increases. After deploying, check response times and CPU usage
- Log size: The audit log tends to grow large, so check that logrotate covers it (on both Ubuntu and AlmaLinux, the packages write it to the same folder as the Apache logs)
- Rule updates: CRS is updated through package updates. Apply OS security updates and check afterward whether false positives have increased
- Don't rely on the WAF alone: Also keep WordPress core, plugins and themes updated, and protect the admin login
Summary
- On Ubuntu 24.04, install
libapache2-mod-security2andmodsecurity-crs; on AlmaLinux 9, installmod_securityandmod_security_crs. CRS comes with the packages, so you don't need to download it separately - On Ubuntu, copy
modsecurity.conf-recommendedto create the configuration file. AlmaLinux starts in blocking mode, so on a live site switch to detection only first - Start with
DetectionOnly, review the logs, exclude false positives, then switch toOn - For WordPress, enable
tx.crs_exclusions_wordpress=1incrs-setup.conf - Write your own rules with
SecRule, give them non-overlappingidvalues, and check them withconfigtest
Related articles:
- How to set up a WAF with Apache mod_security (ModSecurity): Installing OWASP CRS and handling false positives
- How to protect WordPress with ModSecurity (WAF) on Nginx
- ModSecurity vs. fail2ban: differences and when to use each
Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)