Hardening Nginx: Raweb Nginx and ModSecurity WAF Integration
Step-by-step guide to configuring secure virtual hosts, tuning ModSecurity WAF rules, and automating Let's Encrypt SSL in Raweb Nginx.
[!NOTE] Nginx WAF Series: This is Part 2 of our guide. If you haven't installed Raweb Nginx yet, start with Part 1: Nginx Web Application Firewall: Secure Your Site with ModSecurity.
In today's threat landscape, securing your web applications against SQL injection, cross-site scripting (XSS), malicious bots, and automated vulnerability scanners requires a robust defense-in-depth strategy. While a standard Nginx installation is excellent for serving content, it lacks built-in protective features like a Web Application Firewall (WAF) out of the box.
Raweb Nginx solves this by providing a performance-tuned, pre-configured Nginx build packed with ModSecurity WAF, the OWASP Core Rule Set (CRS) foundation, and OpenResty Lua modules. This guide covers how to create secure virtual hosts (vhosts), harden TLS and HTTP/3 QUIC configurations, tune ModSecurity rules to eliminate false positives, and automate Let's Encrypt SSL certificates.
1. Raweb Nginx Directory Architecture
Before modifying configuration files, it is crucial to understand the layout of Raweb Nginx:
/nginx/nginx.conf: The main configuration file. It loads worker settings, HTTP blocks, and includes all sub-configurations./nginx/live/: The folder containing virtual host (vhost) configuration files (e.g.,/nginx/live/yourdomain.conf)./nginx/config/: Holds reusable configuration snippets:ssl.conf: Global TLS settings (protocols, ciphers, TLS tickets, and session parameters).security_headers.conf: HTTP security headers.
/nginx/modsec/: Holds ModSecurity configurations and rules (e.g.,main.conf,modsecurity.conf, andunicode.mapping).
2. Creating a Hardened Virtual Host (Vhost)
All virtual host files should be created in the /nginx/live/ directory. Nginx is configured to load these files automatically via include live/*.conf; in the main configuration.
Below are two standard configuration templates: one for a reverse proxy (e.g., pointing to a Dockerized web application) and one for a standard PHP application.
Option A: Hardened Reverse Proxy (Docker Application)
Create a configuration file at /nginx/live/app.yourdomain.com.conf:
server {
# Listen on HTTP (Port 80) and redirect to HTTPS
listen 80;
listen [::]:80;
server_name app.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
# Listen on HTTPS (Port 443) with HTTP/2 and HTTP/3 QUIC
listen 443 ssl;
listen [::]:443 ssl;
listen 443 quic;
listen [::]:443 quic;
server_name app.yourdomain.com;
# HTTP/3 headers
http2 off; # Set to off if utilizing pure HTTP/3 and fallback, or leave default
http3 on;
more_set_headers 'Alt-Svc: h3=":443";ma=86400';
more_set_headers 'x-quic: h3';
more_set_headers "Priority: $h3_priority";
# SSL Certificate Paths
ssl_certificate /etc/letsencrypt/live/app.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.yourdomain.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/app.yourdomain.com/fullchain.pem;
# Include global SSL configuration parameters
include /nginx/config/ssl.conf;
include /nginx/config/security_headers.conf;
# Enable ModSecurity WAF for this virtual host
modsecurity on;
modsecurity_rules_file /nginx/modsec/main.conf;
# Body Size Control
client_max_body_size 50M;
# Reverse Proxy Location
location / {
proxy_pass http://127.0.0.1:8080; # Port of your backend Docker application
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Early-Data $ssl_early_data; # Fast open/Zero-RTT TLS
}
# Disable WAF for specific static or public paths if necessary
location /static/ {
proxy_pass http://127.0.0.1:8080;
modsecurity off;
}
# Access and Error Logs
access_log /var/log/nginx/app_access.log;
error_log /var/log/nginx/app_error.log;
}
Option B: PHP-FPM Configuration (FastCGI Cache)
If you are hosting a local PHP site (e.g., WordPress or a custom framework), map request headers and route PHP execution to your PHP-FPM sock or port:
server {
listen 80;
server_name yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen 443 quic;
server_name yourdomain.com;
root /var/www/yourdomain.com/public;
index index.php index.html;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
include /nginx/config/ssl.conf;
include /nginx/config/security_headers.conf;
modsecurity on;
modsecurity_rules_file /nginx/modsec/main.conf;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass 127.0.0.1:9000; # Or unix:/var/run/php/php8.2-fpm.sock
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
# Cache control for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
modsecurity off; # Disable WAF for static asset checks to save CPU
}
}
3. Customizing HTTP Security Headers
Open /nginx/config/security_headers.conf to configure security headers. Instead of standard Nginx add_header (which has the downside of being overridden if a nested location block defines its own headers), Raweb Nginx compiles headers-more-nginx-module to use more_set_headers:
# Mask Nginx Server Header
more_clear_headers Server;
more_set_headers "Server: raweb-panel";
# Prevent Clickjacking (X-Frame-Options)
more_set_headers "X-Frame-Options: SAMEORIGIN";
# Prevent XSS Sniffing
more_set_headers "X-Content-Type-Options: nosniff";
# Enable XSS protection in legacy browsers
more_set_headers "X-Xss-Protection: 1; mode=block";
# Referrer Policy
more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
# Content Security Policy (CSP)
more_set_headers "Content-Security-Policy: upgrade-insecure-requests; default-src 'self' 'unsafe-inline' data:; img-src * data: 'self'; font-src 'self' data:;";
# HTTP Strict Transport Security (HSTS)
more_set_headers "Strict-Transport-Security: max-age=31536000; includeSubDomains; preload";
# Permissions Policy
more_set_headers "Permissions-Policy: geolocation=(), midi=(), camera=(), microphone=()";
4. Tuning ModSecurity and Resolving False Positives
ModSecurity intercepts incoming requests in real-time. Sometimes, legitimate requests (like administrative panel updates in Ghost CMS or file uploads in WordPress) trigger a security rule. This is known as a false positive.
Step 1: Set the Engine to Blocking Mode
By default, ModSecurity is configured in DetectionOnly mode to allow testing without blocking users. Once you are ready to enforce blocks, change the directive in /nginx/modsec/main.conf:
# Change this line in /nginx/modsec/main.conf
SecRuleEngine On
Step 2: Analyze ModSecurity Logs
When a request is blocked, Nginx returns a 403 Forbidden response. To locate the specific rule that triggered the block, check the Nginx error log or the ModSecurity audit log:
# View the error log for ModSecurity blocks
tail -f /var/log/nginx/app_error.log | grep ModSecurity
A log entry will look similar to this:
[error] 12345#12345: *98765 [client 203.0.113.10] ModSecurity: Access denied with code 403 (phase 2). Pattern match "select" at ARGS:query. [file "/nginx/modsec/main.conf"] [id "1000"] [msg "sql keywords"] [hostname "app.yourdomain.com"] [uri "/api/search"]
Key information to extract: * Target Field: ARGS:query (the argument that triggered the rule) * Rule ID: 1000 (the rule that blocked the request) * Reason: sql keywords (the pattern matched)
Step 3: Write Rule Exclusion Rules
Rather than disabling ModSecurity entirely or disabling the rule globally, write specific rule exclusions (whitelists) to preserve maximum protection. Exclusions should be appended to the bottom of /nginx/modsec/main.conf.
Example 1: Disable a Rule for a Specific Parameter on a Specific Path
If rule 1000 is blocking search queries containing SQL keywords on /api/search:
# Allow SQL keywords specifically in the "query" parameter on the /api/search URI
SecRuleUpdateTargetById 1000 "!ARGS:query"
Example 2: Disable a Rule Completely for a Specific Directory (e.g., Ghost Admin Portal)
If the administrative dashboard makes requests that trigger complex rules, exclude the path:
# Disable ModSecurity rule processing completely for the Ghost admin directory
SecRule REQUEST_URI "@beginsWith /ghost/" "id:90001,phase:1,pass,nolog,ctl:ruleEngine=Off"
Example 3: Exclude rule checking by ID on a specific location block
Alternatively, you can turn off ModSecurity inside a specific Nginx location block in your virtual host configuration:
location /wp-admin/ {
modsecurity off; # Disables WAF for WordPress Admin console
proxy_pass http://127.0.0.1:8080;
}
5. Automating Let's Encrypt SSL Certificates with Certbot
To automate SSL certificates for your virtual hosts, use Certbot. Because Raweb Nginx is a custom binary located at /nginx/, standard auto-configuration plugins (like --nginx) may not locate Nginx configurations. Instead, use the Webroot authentication method.
Step 1: Install Certbot
On Debian/Ubuntu systems:
sudo apt update
sudo apt install certbot -y
Step 2: Create a Well-Known Directives Snippet
Create a reusable ACME challenge configuration at /nginx/config/letsencrypt.conf:
location ^~ /.well-known/acme-challenge/ {
default_type "text/plain";
root /var/www/letsencrypt;
allow all;
}
Make sure the directory exists and belongs to the Nginx user:
sudo mkdir -p /var/www/letsencrypt
sudo chown -R nginx:nginx /var/www/letsencrypt
Step 3: Include the Snippet in Your Virtual Host
Temporarily update your vhost config file (/nginx/live/app.yourdomain.com.conf) to listen on Port 80 and include the ACME location block:
server {
listen 80;
server_name app.yourdomain.com;
include /nginx/config/letsencrypt.conf;
location / {
return 301 https://$host$request_uri;
}
}
Reload Nginx:
sudo nginx -s reload
Step 4: Issue the Certificate
Run Certbot to request a certificate:
sudo certbot certonly --webroot -w /var/www/letsencrypt -d app.yourdomain.com --agree-tos --email webmaster@yourdomain.com --non-interactive
Once the certificate is successfully issued, update your virtual host file to listen on Port 443, pointing the SSL certificate paths to /etc/letsencrypt/live/app.yourdomain.com/, and reload Nginx again.
Step 5: Automate Renewal via Cron Job
Certbot automatically installs a systemd timer or cron job for renewal. However, you need Nginx to reload after certificates are updated. Add a deploy hook script to handle this:
Create /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh:
#!/bin/bash
/usr/sbin/nginx -t && /usr/sbin/nginx -s reload
Make it executable:
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
This script will run automatically every time a certificate is successfully renewed, ensuring zero downtime and fully automated SSL management.