How To Set Up ModSecurity as a Web Application Firewall With Nginx on Ubuntu 22.04/24.04
Tricknowtech Team 13 min read
Goal
In this tutorial, you will compile ModSecurity as a dynamic Nginx module, connect it to the OWASP Core Rule Set (CRS), and enable it for a real site — starting in non-blocking detection mode, reviewing the audit log for false positives, and only then switching to active blocking.
Prerequisites
An Ubuntu 22.04 or 24.04 server with Nginx already installed and serving at least one site
A non-root user with sudo privileges
Comfort compiling C software from source and reading a project's own README/build docs
An existing site config under /etc/nginx/sites-available you can edit and reload
Let an AI agent do this for you
Copy a ready-made prompt for an AI coding assistant with terminal access to your server (Claude Code, Cursor, or similar) — it can carry out the steps below for you. Review what it plans to run before it executes anything.
ModSecurity is an open-source web application firewall (WAF) engine. It inspects HTTP requests, and optionally responses, against a set of rules and can log — or actively reject — requests that match known attack patterns: SQL injection attempts, path traversal, common exploit-scanner signatures, and similar. It's an additional layer in front of your application, not a substitute for keeping the application and its dependencies patched.
One thing to know before you start: Ubuntu's Nginx package does not ship ModSecurity, and there is no ready-made apt package for the module on 22.04 or 24.04. Getting it running means compiling ModSecurity as a dynamic Nginx module against your exact installed Nginx build, then separately installing a rule set for it to enforce. This is a genuinely more involved setup than a typical apt install — budget real time for it, and expect to rebuild the module again after any future Nginx version upgrade.
Step 1 — Record your current Nginx build
Dynamic Nginx modules must be compiled with configure arguments compatible with the Nginx binary that will load them. Before building anything, check what you're currently running:
bash
nginx -v
nginx -V
nginx -v gives you the version number. nginx -V prints a long "configure arguments:" line — save that line; you'll reuse it unmodified in Step 3 so the module you build matches the options your running server was already compiled with.
Step 2 — Build the ModSecurity core library
ModSecurity ships as two separate pieces: the core rule-matching engine (libModSecurity) and a thin per-web-server connector. For Nginx, that connector is a separate repository. Both live under the OWASP ModSecurity project on GitHub: the core library at owasp-modsecurity/ModSecurity, and the Nginx connector at owasp-modsecurity/ModSecurity-nginx.
“Do not copy a build-dependency apt-get line from anywhere else, including older tutorials. The list of -dev packages ModSecurity needs differs between Ubuntu 22.04 and 24.04 and changes between ModSecurity releases. Open this repository's README on GitHub and follow its "Compilation" section for the exact dependency packages and build commands for the version you cloned — it will typically walk you through a build script, ./configure, make, and sudo make install, but confirm the exact sequence there rather than assuming it.”
Tricknowtech VPS Hosting
Dedicated KVM resources and full root access — deployed in under 60 seconds, no ticket required.
Step 3 — Build the ModSecurity-nginx connector as a dynamic module
With libModSecurity installed, clone the Nginx connector next to it:
bash
cd ~
git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity-nginx
Again, its README is the authority on current build flags and any extra dependencies — read it before proceeding. The general shape of what comes next is: download the Nginx source matching your installed version, then re-run ./configure with the exact arguments you saved in Step 1, plus --add-dynamic-module pointing at the connector you just cloned.
bash
NGINX_VERSION=$(nginx -v 2>&1 | grep -oP '(?<=nginx/)[0-9.]+')
cd ~
wget "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz"
tar xzf "nginx-${NGINX_VERSION}.tar.gz"
cd "nginx-${NGINX_VERSION}"
# Paste the full "configure arguments:" line from `nginx -V` (Step 1) below,
# unmodified, then append --add-dynamic-module:
./configure <paste-your-saved-configure-arguments-here> \
--add-dynamic-module=../ModSecurity-nginx
make modules
sudo cp objs/ngx_http_modsecurity_module.so /etc/nginx/modules/
nginx.org is the project's own official download host, so pulling the matching source tarball from there is safe to do directly. If your configure step errors out on a flag or path, that's the connector README's job to resolve for your specific Nginx build — check it there rather than guessing.
Step 4 — Load the module and set ModSecurity's base configuration
Tell Nginx to load the compiled module by adding a load_module directive near the top of the main config file, before the events block:
ModSecurity itself needs its own configuration directory. The ModSecurity repository you cloned includes a recommended starting config and a Unicode mapping file — copy both into a config directory under Nginx:
Leave SecRuleEngine set to DetectionOnly for now — more on exactly why in Step 7. This is the single most important setting in this whole setup to get right, and the most commonly gotten wrong.
Step 5 — Install the OWASP Core Rule Set
ModSecurity is just the engine — on its own it has no rules and blocks nothing. The standard, widely used open-source rule set is the OWASP Core Rule Set (CRS), maintained at github.com/coreruleset/coreruleset. Follow that repository's own installation instructions for the current stable release and download method (a release tarball is typical), rather than pulling an arbitrary git tag from memory.
Once you've fetched a CRS release into, for example, /etc/nginx/modsec/coreruleset, create its local config from the example it ships:
bash
cd /etc/nginx/modsec/coreruleset
sudo cp crs-setup.conf.example crs-setup.conf
Now tie the base config and the rule set together into one file that Nginx will point at:
text
# /etc/nginx/modsec/main.conf
Include /etc/nginx/modsec/modsecurity.conf
Include /etc/nginx/modsec/coreruleset/crs-setup.conf
Include /etc/nginx/modsec/coreruleset/rules/*.conf
Step 6 — Enable ModSecurity for the site you want to protect
Edit the server block for the site — for example /etc/nginx/sites-available/example.com — and add two directives inside the server{} block (or a specific location{} block, if you only want a particular path inspected):
You don't have to enable this on every server block on the box. Add modsecurity on; only to the server{} or location{} blocks actually serving traffic you want inspected — an internal admin vhost or a static-only vhost may not need it, and every block you add it to costs some request latency.
This WAF operates at the application layer and doesn't replace your network-level firewall (ufw/iptables) restricting which ports are reachable in the first place — leave that in place exactly as it was.
Validate and reload:
bash
sudo nginx -t
sudo systemctl reload nginx
Always run nginx -t before reloading, and after any change to these files going forward. It catches config syntax errors before they can take the site down; reload with a broken config file just fails safely and keeps the old config running, but it's still worth catching early.
Step 7 — Run in DetectionOnly mode and read the audit log
SecRuleEngine's three modes
On — actively evaluates every request against the rule set and blocks (typically with a 403) any request that matches.
DetectionOnly — evaluates every request and logs what would have matched, but does not block anything. Traffic flows normally.
Off — the engine does nothing; requests pass through unexamined.
“Leave SecRuleEngine set to DetectionOnly for several days to a week on a production site before ever switching it to On. A freshly enabled CRS with default settings commonly flags legitimate traffic as malicious — an unusually long form field, a login POST containing punctuation, an API client sending a JSON body with certain characters. If you go straight to On, you will very likely block real users before you've had a chance to see what your own traffic actually looks like to the rule set.”
While in DetectionOnly, watch the audit log ModSecurity is writing to:
bash
sudo tail -f /var/log/modsec_audit.log
Each matched request logs an entry that includes a rule ID and message, for example a fragment like [id "941100"] [msg "XSS Attack Detected"]. During the detection period, note any rule IDs that fire against requests you know are legitimate for your application — you'll use those IDs in the Troubleshooting section at the end of this tutorial if needed. Also be aware this log can capture full request bodies, including login form fields — treat it as sensitive, and restrict its read permissions to root.
bash
sudo chmod 640 /var/log/modsec_audit.log
Step 8 — Switch to blocking mode
Once you've reviewed the audit log for long enough to be confident real traffic isn't tripping rules — or you've added exclusions for the ones that were — switch the engine on:
text
SecRuleEngine On
bash
sudo nginx -t
sudo systemctl reload nginx
Step 9 — Verify it's actually blocking
Send a request containing an obvious attack pattern in the query string — a classic SQL injection test string — and confirm it's rejected:
This should print 200 (or whatever your site normally returns for that path). If the first request isn't blocked, double-check SecRuleEngine is actually On in the file Nginx is reading (not a stale copy) and that you reloaded after the change.
Troubleshooting: legitimate traffic gets blocked after switching to On
This will happen eventually, even after a careful detection period — some legitimate request shape you didn't see during testing trips a CRS rule once live. The fix is not to turn the engine back off; that removes the protection you just spent this whole tutorial setting up. Instead:
Find the blocked request's timestamp and pull the matching entry from /var/log/modsec_audit.log — it will include the specific rule ID that matched, in a line like [id "941100"].
Add a targeted exclusion for that rule ID, scoped as narrowly as you can — ideally to just the one request field or the one location where it's a false positive, not a blanket removal.
Load the exclusion after the CRS rules are included, so it can override them — for example a separate /etc/nginx/modsec/exclusions.conf containing SecRuleRemoveById 941100, included at the end of main.conf.
Consult the Core Rule Set's own documentation for its recommended exclusion patterns (it documents narrower options than a blanket SecRuleRemoveById, such as excluding just one target field on one rule for one URL) before settling on the broadest version of a fix.
# /etc/nginx/modsec/main.conf
Include /etc/nginx/modsec/modsecurity.conf
Include /etc/nginx/modsec/coreruleset/crs-setup.conf
Include /etc/nginx/modsec/coreruleset/rules/*.conf
Include /etc/nginx/modsec/exclusions.conf
Reload after any change to these files (sudo nginx -t && sudo systemctl reload nginx), and re-test the specific request that was previously blocked to confirm the exclusion worked without reopening the door the rule was there to close.