How to Protect Nginx with Coraza WAF Using Docker
Step-by-step guide to deploying Coraza WAF as a reverse proxy in front of Nginx using Docker and docker-compose, with OWASP CRS protection out of the box.
Coraza is a modern, open source WAF written in Go that is fully compatible with ModSecurity rules and the OWASP Core Rule Set (CRS). While Coraza does not have a native Nginx module, the recommended approach is to run Caddy with the coraza-caddy plugin as a WAF reverse proxy in front of Nginx.
This guide walks you through two Docker-based approaches: a docker-compose setup where Caddy+Coraza sits in front of an existing Nginx container, and a single-container approach for simpler deployments. By the end, you will have Nginx protected by Coraza WAF with full OWASP CRS v4 coverage, blocking SQL injection, XSS, command injection, and other common attacks.
Prerequisites
- Docker and docker-compose installed on your system
- Basic familiarity with Docker and Nginx configuration
- An Nginx site or application you want to protect (or follow along with the example)
Step-by-Step Guide
Understand the Architecture
Because Coraza does not have a native Nginx module, the setup uses Caddy as a WAF reverse proxy. The request flow is:
Client -> Caddy + Coraza WAF (:8080) -> Nginx (:80) -> Your Application
Caddy handles TLS termination (if needed) and runs the Coraza WAF engine. Clean requests are forwarded to Nginx. Malicious requests are blocked before they reach your application.
This is a standard reverse proxy pattern. Your Nginx configuration stays exactly the same. You only need to add the Caddy+Coraza layer in front of it.
Create the Project Directory
Create a directory structure for your setup:
mkdir -p coraza-nginx/{caddy,nginx} cd coraza-nginx
You will end up with three files:
docker-compose.yml- orchestrates both containerscaddy/Dockerfile- builds Caddy with the Coraza plugincaddy/Caddyfile- configures the WAF rules and reverse proxy
Create the Caddy+Coraza Dockerfile
The official Caddy Docker image does not include the Coraza plugin. You need to build a custom image using xcaddy, which is Caddy's official build tool for adding plugins:
FROM caddy:2-builder AS builder
RUN xcaddy build \ --with github.com/corazawaf/coraza-caddy/v2
FROM caddy:2
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Save this as caddy/Dockerfile.
This multi-stage build compiles Caddy with the coraza-caddy plugin in the builder stage, then copies the binary into the slim runtime image. The resulting image is the same size as the standard Caddy image, just with WAF capabilities added.
Write the Caddyfile Configuration
Create the Caddyfile that configures Coraza WAF and proxies traffic to Nginx:
{ auto_https off order coraza_waf first }
:8080 { coraza_waf { load_owasp_crs directives ` Include @coraza.conf-recommended Include @crs-setup.conf.example Include @owasp_crs/*.conf SecRuleEngine On ` }
reverse_proxy nginx:80 }
Save this as caddy/Caddyfile.
Key configuration explained:
order coraza_waf first- ensures WAF runs before any other handlerload_owasp_crs- loads the bundled OWASP Core Rule Set (CRS v4)Include @coraza.conf-recommended- loads Coraza's recommended defaultsInclude @crs-setup.conf.example- loads CRS default configurationInclude @owasp_crs/*.conf- loads all CRS detection rulesSecRuleEngine On- enables request blocking (not just logging)reverse_proxy nginx:80- forwards clean traffic to Nginx
Create the docker-compose.yml
Create the docker-compose file that runs both services:
services: caddy: build: context: ./caddy ports: - "8080:8080" volumes: - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro depends_on: - nginx
nginx: image: nginx:alpine volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/html:/usr/share/nginx/html:ro
Notice that Nginx does not expose any ports to the host. All traffic goes through Caddy+Coraza first. Only port 8080 is exposed, which is the WAF-protected entry point.
Add a Test Page to Nginx
Create a simple test page to verify the setup works:
mkdir -p nginx/html
cat > nginx/default.conf <<'EOF' server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ =404; } } EOF
cat > nginx/html/index.html <<'EOF' <!DOCTYPE html> <html> <head><title>Protected by Coraza WAF</title></head> <body> <h1>It works!</h1> <p>This page is served by Nginx, protected by Coraza WAF.</p> </body> </html> EOF
Build and Start the Stack
Build the custom Caddy image and start both containers:
docker compose build docker compose up -d
The first build takes 1-2 minutes as it compiles Caddy with the Coraza plugin. Subsequent starts are instant because Docker caches the built image.
Verify both containers are running:
docker compose ps
You should see both caddy and nginx containers with status "Up".
Test Normal Traffic
Open your browser or use curl to verify normal requests work:
curl http://localhost:8080/
You should see your test page ("It works!"). This confirms that:
- Caddy is receiving traffic on port 8080
- Coraza WAF is inspecting the request and allowing it through
- Nginx is serving the page through Caddy's reverse proxy
Test WAF Blocking
Now test that the WAF actually blocks attacks. Try these common attack payloads:
# SQL injection attempt - should return 403 curl -o /dev/null -w "HTTP %{http_code}\n" "http://localhost:8080/?id=1 OR 1=1"
# XSS attempt - should return 403 curl -o /dev/null -w "HTTP %{http_code}\n" "http://localhost:8080/?q=<script>alert(1)</script>"
# Scanner detection - should return 403 curl -o /dev/null -w "HTTP %{http_code}\n" -H "User-Agent: nikto" http://localhost:8080/
# Command injection - should return 403 curl -o /dev/null -w "HTTP %{http_code}\n" "http://localhost:8080/?cmd=;cat /etc/passwd"
All of these should return HTTP 403 (Forbidden). If they return 200, check that SecRuleEngine On is set in your Caddyfile.
View WAF Logs
Check the Caddy logs to see what Coraza is blocking and why:
docker compose logs caddy | grep "waf"
Each blocked request generates a log entry showing:
- The CRS rule that triggered (e.g.,
REQUEST-941-APPLICATION-ATTACK-XSS) - The rule ID and severity
- The matched data (what part of the request triggered the rule)
- The anomaly score and blocking threshold
This is the same OWASP CRS logging format used by ModSecurity, so existing log analysis tools and guides apply.
Adapt for Production
For a production deployment, make these changes to your Caddyfile:
{ order coraza_waf first }
yourdomain.com { coraza_waf { load_owasp_crs directives ` Include @coraza.conf-recommended Include @crs-setup.conf.example Include @owasp_crs/*.conf SecRuleEngine On SecAuditEngine On SecAuditLog /var/log/coraza/audit.log SecAuditLogFormat json ` }
reverse_proxy nginx:80 }
Changes from the test setup:
- Removed
auto_https offso Caddy handles TLS automatically with Let's Encrypt - Replaced
:8080with your actual domain name - Added audit logging (
SecAuditEngine) for security monitoring - Added JSON audit log format for easier parsing
Also update docker-compose to expose ports 80 and 443 instead of 8080, and add a volume for audit logs.
Conclusion & Next Steps
You now have Nginx protected by Coraza WAF with the OWASP Core Rule Set v4. The setup blocks SQL injection, XSS, command injection, scanner probes, and hundreds of other attack patterns out of the box.
What you have:
- Coraza WAF running CRS v4.25 in front of Nginx
- Automatic blocking of OWASP Top 10 attacks
- Detailed logging of all blocked requests
- A clean separation between WAF and application layers
Next steps:
- Monitor WAF logs for false positives and tune rules as needed
- Add custom
SecRuledirectives for application-specific protection - Set up a custom 403 error page using Caddy's
handle_errorsdirective - Consider adding rate limiting in Caddy for DDoS protection
- For Kubernetes deployments, see the Coraza provider page for ingress controller options
Troubleshooting
Build fails with "xcaddy: command not found"
Make sure you are using the caddy:2-builder base image in your Dockerfile. This image includes xcaddy. Do not use the regular caddy:2 image for the builder stage.
Normal requests return 403
Your application may be triggering CRS rules (false positives). Check the logs with docker compose logs caddy | grep "waf" to find which rule is firing. You can add rule exclusions in the Caddyfile directives:
SecRuleRemoveById 942100
Replace 942100 with the actual rule ID from your logs.
Caddy cannot connect to Nginx
Make sure both services are on the same Docker network (docker-compose handles this automatically). Verify the service name in reverse_proxy matches the service name in docker-compose.yml. Check that Nginx is healthy with docker compose logs nginx.
Changes to Caddyfile not taking effect
Restart the Caddy container after changing the Caddyfile:
docker compose restart caddy
Since the Caddyfile is mounted as a read-only volume, you do not need to rebuild.
Performance concerns
The Caddy+Coraza layer adds single-digit millisecond overhead per request with the default CRS configuration. For high-traffic sites, you can reduce overhead by lowering the CRS paranoia level or disabling specific rule categories you do not need.
Frequently Asked Questions
Why use Caddy instead of running Coraza directly with Nginx?
Coraza does not have a native Nginx module. ModSecurity has one (the ModSecurity-nginx connector), but Coraza takes a different approach: it is a Go library that integrates with Go-based proxies. Caddy with the coraza-caddy plugin is the most popular and best-supported integration. The reverse proxy pattern is widely used in production and keeps your Nginx configuration untouched.
Can I use this setup with an existing Nginx that is already running?
Yes. If Nginx is running on the host (not in Docker), run the Caddy+Coraza container and change the reverse_proxy line to point to your host's IP and Nginx port. For example: reverse_proxy host.docker.internal:80 (on Docker Desktop) or reverse_proxy 172.17.0.1:80 (on Linux). Then update your DNS or load balancer to send traffic to the Caddy container's port instead of directly to Nginx.
Does this work with Nginx Plus?
Yes. The Caddy+Coraza layer sits in front of Nginx as a reverse proxy. It does not care whether the backend is Nginx open source or Nginx Plus. The WAF inspects traffic before it reaches Nginx, so all Nginx Plus features (load balancing, health checks, etc.) work exactly the same way.
How do I update the CRS rules?
The CRS rules are bundled inside the coraza-caddy module. To update, rebuild your Caddy image with docker compose build --no-cache. This pulls the latest coraza-caddy version, which includes the latest bundled CRS. You can pin a specific version in the Dockerfile: --with github.com/corazawaf/coraza-caddy/[email protected].
What is the performance overhead?
In testing, Coraza with the default CRS configuration adds 1-5ms of latency per request. This is comparable to ModSecurity. The overhead depends on the number of enabled rules and the paranoia level. For most applications, the overhead is negligible compared to application response times.