A web application firewall (WAF) using Coraza and the OWASP Core Rule Set deployed as a reverse proxy service in front of your application on Convox.
This example demonstrates how to protect a backend service with an in-cluster WAF that blocks common web attacks — SQL injection, cross-site scripting (XSS), local file inclusion, and more — without any changes to your application code. The WAF runs as a separate Convox service using a pre-built Docker image maintained by the OWASP CRS project, so there's nothing to compile or patch.
Deploy to Convox Cloud for a fully-managed platform experience, or to your own Convox Rack for complete control over your infrastructure. Either way, you'll get automatic SSL, load balancing, and zero-downtime deployments out of the box.
Internet → NLB → nginx ingress → waf service (Caddy + Coraza) → web service (internal)
The waf service is the public-facing entry point. It inspects every request through Coraza and the OWASP Core Rule Set, then forwards clean traffic to the web service. Setting internal: true on web ensures nothing bypasses the WAF.
-
Create a Cloud Machine at console.convox.com
-
Create the app:
convox cloud apps create coraza-waf -i your-machine-name- Deploy the app:
convox cloud deploy -a coraza-waf -i your-machine-name- View your app:
convox cloud services -a coraza-waf -i your-machine-name- Create the app:
convox apps create coraza-waf- Deploy the app:
convox deploy -a coraza-waf- View your app:
convox services -a coraza-wafOnce deployed, grab the WAF service URL from convox services output.
WAF_URL="https://your-waf-url"
# Normal request — should return 200
curl -sk "$WAF_URL/"
# SQL injection — should return 403 when enforcement is on
curl -sk "$WAF_URL/?id=1%20OR%201=1--"
# XSS — should return 403 when enforcement is on
curl -sk "$WAF_URL/?q=<script>alert(1)</script>"
# .env probe — should return 403 when enforcement is on
curl -sk "$WAF_URL/.env"This example starts in tuning mode — the rule engine is on but the anomaly score threshold is set high enough that nothing gets blocked. Malicious requests are logged so you can tune out false positives before enabling enforcement.
| Variable | Default | Description |
|---|---|---|
CORAZA_RULE_ENGINE |
On |
Must be On for paranoia level gating to work correctly. See note below. |
ANOMALY_INBOUND |
999999 |
Anomaly score threshold before a request is blocked. Set to 999999 for tuning (log only), 5 for enforcement. |
BACKEND |
web:3000 |
Backend service address. Use the Convox service name and port. |
PORT |
8080 |
Port the WAF container listens on. |
Why not DetectionOnly? The Coraza WAF engine has a bug where skipAfter flow-control actions — the mechanism CRS uses for paranoia level gating — do not execute in DetectionOnly mode. This causes all rules at all paranoia levels to fire regardless of your configured paranoia level, flooding logs with false positives. Using On mode with a high anomaly threshold gives the same log-only behavior but with correct paranoia gating.
Note on paranoia level: The PARANOIA and BLOCKING_PARANOIA environment variables are passed to the image's entrypoint script but have known issues with CRS v4 variable names. This example sets the paranoia level directly in waf/plugins/paranoia-before.conf as the authoritative control. To change the paranoia level, edit that file and update the setvar values.
- Start in tuning mode (the default). The rule engine is
Onbut the anomaly threshold is set to999999so nothing gets blocked:
environment:
- CORAZA_RULE_ENGINE=On
- ANOMALY_INBOUND=999999- Watch the logs for what Coraza flags:
convox logs -a coraza-waf | grep "paranoia-level"-
Tune out false positives by adding custom rule exclusions (see Custom Rules below).
-
Enable enforcement by lowering the anomaly threshold to
5(the CRS default):
convox env set ANOMALY_INBOUND=5 -a coraza-waf- Raise paranoia level once stable — edit
waf/plugins/paranoia-before.confand change both values to2, then redeploy:
SecAction "id:1000002,phase:1,nolog,pass,t:none,setvar:tx.blocking_paranoia_level=2,setvar:tx.detection_paranoia_level=2"
| Level | Coverage | False Positives | Best For |
|---|---|---|---|
| 1 | Catches obvious attacks | Very few | Getting started |
| 2 | Broader coverage | Some tuning needed | Most production apps |
| 3 | Extensive rules | Significant tuning | High-security environments |
| 4 | Maximum coverage | Expect heavy tuning | Compliance-driven use cases |
Replace the example backend with your own service. The only changes needed are in convox.yml:
services:
waf:
build: ./waf
port: 8080
health:
path: /health
interval: 10
timeout: 9
environment:
- CORAZA_RULE_ENGINE=On
- ANOMALY_INBOUND=999999
- BACKEND=web:3000
- PORT=8080
scale:
count: 2
memory: 512
web:
build: .
port: 3000
internal: truePoint BACKEND to your service name and port. Convox configures DNS search domains on every pod, so the bare service name resolves automatically within the same app — no need for a fully-qualified domain name.
Health check tip: Point the health check at a lightweight backend endpoint (like /health) rather than / to avoid the health check going through full WAF inspection and hitting any backend rate limiting.
The WAF image supports three directories for customization, loaded at different times:
| Directory | Loaded | Use For |
|---|---|---|
/opt/coraza/config.d/ |
Before crs-setup.conf |
Rule exclusions (ctl:ruleRemoveById) |
/opt/coraza/plugins/ |
After crs-setup.conf, before CRS rules |
Paranoia level overrides |
/opt/coraza/rules.d/ |
After CRS rules | Additional rules, blanket removals |
This distinction matters: conditional exclusions using ctl:ruleRemoveById must go in config.d/ so they load before the rules they're suppressing. Paranoia level settings must go in plugins/ so they load after crs-setup.conf (which would otherwise overwrite them) but before the CRS rules evaluate them.
This example ships with a config.d/convox-exclusions.conf that suppresses false positives from normal Convox platform behavior:
- Rule 920350 (Host header is numeric IP) — Kubernetes health checks hit the pod IP directly, so the Host header is always a numeric IP. This is expected behavior, not an attack. The exclusion only applies to requests with a
kube-probeuser-agent so normal traffic is unaffected.
Edit waf/custom-rules/application-exclusions.conf to add exclusions specific to your application. The file includes commented-out examples for common patterns.
Important: Use single-line format for SecRule directives. Backslash line continuations can fail silently in Coraza depending on file encoding and trailing whitespace.
Exclude third-party cookies from inspection — CloudFront signed cookies, Google Analytics (_ga), and Cloudflare (_cf*) cookies contain base64, timestamps, and special characters that trigger SQL injection and RCE rules as false positives:
SecRule REQUEST_URI "@rx .*" "id:1000100,phase:1,pass,nolog,ctl:ruleRemoveTargetById=942420;REQUEST_COOKIES,ctl:ruleRemoveTargetById=942421;REQUEST_COOKIES,ctl:ruleRemoveTargetById=932240;REQUEST_COOKIES"
Exclude a rule for a specific URL path — when an endpoint legitimately triggers a rule (e.g., a search endpoint or a socket.io endpoint):
SecRule REQUEST_URI "@beginsWith /api/search" "id:1000101,phase:1,pass,nolog,ctl:ruleRemoveById=942100"
SecRule REQUEST_URI "@beginsWith /socket.io/" "id:1000102,phase:1,pass,nolog,ctl:ruleRemoveById=921180"
Exclude a rule for a specific parameter — when a form field contains content that looks like an attack (e.g., a rich text editor that sends HTML):
SecRule REQUEST_URI "@beginsWith /api/posts" "id:1000103,phase:1,pass,nolog,ctl:ruleRemoveTargetById=941100;ARGS:body,ctl:ruleRemoveTargetById=941160;ARGS:body"
Blanket-remove a rule entirely — as a last resort when a rule causes widespread false positives:
SecRuleRemoveById 920300
Note: if your exclusion uses ctl:ruleRemoveById or ctl:ruleRemoveTargetById, move it to waf/config.d/ instead. Blanket removals with SecRuleRemoveById work from either directory. Always use single-line format — backslash line continuations can fail silently.
waf/
├── Dockerfile # Extends the base Coraza CRS image
├── config.d/
│ └── convox-exclusions.conf # Platform exclusions (loaded before crs-setup.conf)
├── plugins/
│ └── paranoia-before.conf # Paranoia level override (loaded after crs-setup.conf)
└── custom-rules/
└── application-exclusions.conf # Your app-specific exclusions (loaded after CRS rules)
On startup you'll see confirmation that both directories were loaded:
- User configuration files loaded from /opt/coraza/config.d
- User defined rule sets loaded from /opt/coraza/rules.d
.
├── convox.yml # Convox deployment configuration
├── waf/
│ ├── Dockerfile # Extends Coraza CRS with custom rules
│ ├── config.d/ # Rule exclusions (loaded before crs-setup.conf)
│ │ └── convox-exclusions.conf
│ ├── plugins/ # Paranoia level override (loaded after crs-setup.conf)
│ │ └── paranoia-before.conf
│ └── custom-rules/ # Additional rules (loaded after CRS rules)
│ └── application-exclusions.conf
└── backend/
├── Dockerfile # Simple Node.js backend
└── server.js # Example application server
The Coraza CRS Docker project offers both nginx and caddy variants. This example uses caddy-alpine because:
- Dynamic DNS resolution — Caddy resolves backend addresses on each request. Nginx resolves at startup and crashes if the backend isn't ready yet, which is a common race condition in Kubernetes.
- Smaller image — Alpine-based, lighter footprint.
- Same WAF behavior — Both variants use identical Coraza and CRS rule sets.
convox cloud scale waf --count 3 --cpu 512 --memory 1024 -a coraza-waf -i your-machine-nameconvox scale waf --count 3 --cpu 512 --memory 1024 -a coraza-wafScale the WAF and backend services independently based on traffic patterns.
Cloud:
convox cloud logs -a coraza-waf -i your-machine-name | grep "transaction"Rack:
convox logs -a coraza-waf | grep "transaction"Cloud:
convox cloud exec waf sh -a coraza-waf -i your-machine-nameRack:
convox run waf sh -a coraza-waf- OWASP Coraza WAF — Project home
- coraza-crs Docker image — Pre-built Caddy/nginx + Coraza + CRS
- CRS documentation — Rule tuning, paranoia levels, exclusions
- Coraza Caddy module — The underlying Caddy plugin