A web application firewall you run yourself no longer means wrestling with a stalled C module. OWASP Coraza 3.8.1 is the newest release, and the project tells users on any earlier 3.x release to upgrade. This tutorial walks through a complete Coraza WAF setup on Ubuntu, using Caddy as the reverse proxy and the OWASP Core Rule Set (CRS) as the rulebook. You finish with a working WAF that blocks SQL injection, cross-site scripting and path traversal in front of a real upstream app.
Managed WAFs are the easy answer, and our guide to setting up Cloudflare WAF covers that route. A self-hosted engine makes sense when traffic cannot leave your network, when you want rules in version control, or when you refuse to pay per request. Coraza is a Go implementation of the ModSecurity rule language (SecLang), so the same CRS files that protect Apache and Nginx fleets also run here. The build takes about 60 minutes if you follow the 13 steps below in order.
·
Google · Preferred Sources
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Add Now
Why Coraza and the Core Rule Set in 2026
Three things changed this year that make the Coraza WAF setup worth doing now. First, the CRS project published an LTS branch. CRS 4.25.0 became the first long-term-support release of CRS 4 in March 2026, and the project says the 4.25.x line receives security fixes until Q3 2027. Second, the older CRS 3.3 line reached the end of its support window in Q3 2026, according to the project’s LTS timeline announcement. If your WAF still loads 3.3 rules, you are running an unsupported rulebook today.
Third, Coraza got cheaper to run. A March 2026 engineering post from the project reported that sharing compiled regex and Aho-Corasick patterns lets each additional WAF instance use roughly 2.1 MiB instead of 21.5 MiB. That matters on a small VPS, and it matters even more when you run one WAF instance per virtual host. The OWASP Developer Guide also lists Coraza next to ModSecurity as a supported CRS engine, and the CRS migration material confirms support for ModSecurity v2, ModSecurity v3 (libmodsecurity) and Coraza.
Coraza ships in two flavors that matter for this tutorial. You can embed the engine in a Go program or proxy, or you can attach it to an existing server through a connector. Connectors exist for Caddy, Nginx, HAProxy and proxy-Wasm hosts such as Envoy. We use Caddy because the connector compiles straight into the web server binary, needs no external service and keeps the config in one file.
We pin CRS 4.25.2 LTS rather than the 4.30.0 mainline release on purpose. A WAF rulebook is production code. LTS point releases change rules only for security and correctness, so a Tuesday update will not suddenly start blocking your checkout form. If you want the newest detection rules and have a staging environment, swap one variable in Step 5 and keep everything else identical.
Prerequisites and Versions You Need
You need a fresh Ubuntu host with root access, a shell you trust and about 60 minutes. Canonical recommends the 26.04 LTS release for long-running workloads, as its 26.10 release notes point out, and it is the baseline for every command here. The 26.10 interim release also works, but its support ends in July 2027, which is a poor fit for something that guards production traffic. Commands for the 24.04 LTS release are identical, so older servers need no changes.
Two version notes save trouble later. The coraza-caddy connector pulls in its own copy of the Coraza engine, so the engine version you actually run depends on the connector release you build. Check it after the build in Step 3 rather than assuming. And never confuse the Coraza engine version with the CRS version. They are separate projects with separate release trains, which is why the table lists them on separate rows.
How the Pieces Fit Together
Picture a request arriving at port 8080. Caddy accepts it and hands it to the Coraza handler before any proxying happens. Coraza runs the request through five processing phases: request headers, request body, response headers, response body and logging. In each phase, CRS rules inspect the traffic and add points to an anomaly score. A SQL injection signature might add 5 points. A suspicious user agent might add 2. After the request phases, a final CRS rule compares the total against a threshold and blocks the request when the score reaches it.
This anomaly scoring model is the key idea to carry through the tutorial. Individual rules do not block anything on their own. They vote. That design lets you tune the WAF by raising or lowering one threshold, or by silencing a single noisy rule, without rewriting the rulebook. It also explains why the safest rollout runs in detection mode first, collects votes for a few days and only then starts blocking.
If you already terminate TLS on Nginx and run the Certbot flow from our Certbot setup on Ubuntu guide, the Nginx connector is the lower-friction path. Everything in Steps 5 through 13 (the rules, tuning and log analysis) carries over unchanged, because the rule language is the same on every connector. Only Steps 2, 3, 8 and 9 are Caddy specific.
Step 1: Prepare the Ubuntu Host
Start from a clean server, patch it and install the small toolset the rest of the tutorial uses. Create a dedicated system user for the WAF process so a bug in a parser never runs as root. If you need a refresher on host-level audit trails before you expose a new service, our auditd setup on Ubuntu walkthrough pairs well with this build.
sudo apt update && sudo apt -y full-upgrade sudo apt -y install curl jq git tar ufw ca-certificates # Dedicated, login-less service account sudo useradd --system --home /var/lib/caddy-waf --create-home \ --shell /usr/sbin/nologin caddywaf # Directories used throughout the tutorial sudo mkdir -p /etc/coraza/custom /var/log/coraza /var/log/caddy-waf /var/www/demo sudo chown caddywaf:caddywaf /var/log/coraza /var/log/caddy-waf # Firewall: SSH, web, and the test port sudo ufw allow OpenSSH sudo ufw allow 80,443/tcp sudo ufw allow 8080/tcp sudo ufw --force enable
Expected output ends with Firewall is active and enabled on system startup. If you are connected over SSH, the allow OpenSSH line must come before enable, otherwise you lock yourself out.
Step 2: Install Go and xcaddy
Caddy plugins are compiled in, not loaded at runtime. That means you need a Go toolchain once, at build time. Install Go from snap so you get a current release instead of the older one in the distro archive, then install xcaddy, the official custom-build helper for Caddy.
sudo snap install go --classic go version # xcaddy lands in ~/go/bin go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest export PATH="$PATH:$HOME/go/bin" xcaddy version
The output shows a Go version string and an xcaddy version. If xcaddy: command not found appears, the PATH export did not apply to your current shell. Add it to ~/.profile and log in again.
Step 3: Build Caddy With the Coraza Module
This is the step that turns plain Caddy into a WAF. The coraza-caddy/v2 module registers a new handler and a coraza_waf directive. Build into a separate file name so the package-managed Caddy, if you ever install one, never gets overwritten.
cd /tmp xcaddy build --with github.com/corazawaf/coraza-caddy/v2 \ --output /tmp/caddy-coraza sudo install -m 0755 /tmp/caddy-coraza /usr/local/bin/caddy-coraza caddy-coraza version caddy-coraza list-modules | grep -i waf
The first build downloads dependencies and takes a few minutes on a small VPS. A good result prints a Caddy version and the line http.handlers.waf. To see which Coraza engine version got baked in, run the command below. It reads the module list from the binary’s embedded build info.
go version -m /usr/local/bin/caddy-coraza | grep -E 'corazawaf/(coraza|coraza-caddy)'
Look for a github.com/corazawaf/coraza/v3 line. If it shows a version below 3.8.1, force the newer engine by rebuilding with --with github.com/corazawaf/coraza/[email protected] added to the xcaddy command. The Coraza release notes describe 3.8.1 as the version that completed fixes left partial in 3.8.0, including a process-crashing bug, so do not settle for an older engine.
Step 4: Create a Demo Upstream App
A WAF needs something to protect. For the tutorial, a tiny Python web server on loopback is enough. It answers every request with the static page you give it, which lets us measure the WAF’s behavior without app logic getting in the way. Swap in your real application later by changing a single address in the Caddyfile.
echo 'Demo upstream is alive' | sudo tee /var/www/demo/index.html sudo tee /etc/systemd/system/demo-upstream.service >/dev/null <<'EOF' [Unit] Description=Demo upstream behind the WAF After=network.target [Service] User=caddywaf ExecStart=/usr/bin/python3 -m http.server 8081 --bind 127.0.0.1 --directory /var/www/demo Restart=on-failure [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now demo-upstream curl -s http://127.0.0.1:8081/
The final curl prints Demo upstream is alive. Because the server binds to 127.0.0.1, nothing outside the machine can reach it directly. That detail matters: a WAF that can be bypassed by hitting the origin port is decoration. Keep real upstreams on loopback or on a private network that only the proxy can reach.
Step 5: Download and Pin the Core Rule Set
Download the exact CRS release you want and keep it in its own directory. Using a variable for the version makes upgrades a one-line change. Per the OWASP Developer Guide, CRS is built for ModSecurity-compatible engines, and Coraza is one of them, so the stock rule files load without modification.
CRS_VERSION=4.25.2 CORAZA_VERSION=3.8.1 cd /tmp curl -fsSL -o crs.tar.gz \ "https://github.com/coreruleset/coreruleset/archive/refs/tags/v${CRS_VERSION}.tar.gz" sudo mkdir -p /opt/crs/v${CRS_VERSION} sudo tar -xzf crs.tar.gz -C /opt/crs/v${CRS_VERSION} --strip-components=1 sudo ln -sfn /opt/crs/v${CRS_VERSION} /etc/coraza/crs # CRS needs a setup file. The example file keeps the version marker rule intact. sudo cp /etc/coraza/crs/crs-setup.conf.example /etc/coraza/crs-setup.conf # Recommended Coraza base config from the engine repo, pinned to the same tag sudo curl -fsSL -o /etc/coraza/coraza.conf \ "https://raw.githubusercontent.com/corazawaf/coraza/v${CORAZA_VERSION}/coraza.conf-recommended" ls /etc/coraza/crs/rules | head -n 8 sudo grep -c '^SecRule' /etc/coraza/crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf
The listing shows files such as REQUEST-901-INITIALIZATION.conf and REQUEST-911-METHOD-ENFORCEMENT.conf, and the SQL injection file returns a rule count in the dozens. The symlink at /etc/coraza/crs is the trick that makes upgrades safe. To move to a new release later, you unpack it beside the old one, repoint the link and restart. Rolling back means pointing the link at the previous directory.
Verify what you downloaded before you trust it. The CRS project publishes signed releases, so check the detached signature and the checksum from the release page against the archive. If you want the newest mainline rules on a staging box, set CRS_VERSION=4.30.0 instead, rerun the block and compare behavior against the LTS line before you promote it.
Step 6: Write the Base Coraza Overrides
The recommended config you downloaded starts in DetectionOnly mode, which is exactly what we want for the first days. Rather than editing that file in place, put your choices in a small override file that loads right after it. When a directive appears twice, the later one wins, so your file stays short and an engine upgrade never overwrites your decisions.
sudo tee /etc/coraza/custom/00-overrides.conf >/dev/null <<'EOF' # Start safe: log matches, block nothing. Flip to On in Step 13. SecRuleEngine DetectionOnly # Inspect request bodies (needed for JSON and form attacks) SecRequestBodyAccess On SecRequestBodyLimit 13107200 SecRequestBodyNoFilesLimit 131072 SecRequestBodyLimitAction Reject # Skip response bodies at first: cheaper and fewer surprises SecResponseBodyAccess Off # Audit log: only transactions that matched something, as JSON SecAuditEngine RelevantOnly SecAuditLogRelevantStatus "^(?:5|4(?!04))" SecAuditLogParts ABIJDEFHZ SecAuditLogType Serial SecAuditLogFormat JSON SecAuditLog /var/log/coraza/audit.log EOF sudo chown -R root:caddywaf /etc/coraza sudo chmod -R g+rX /etc/coraza
Each line earns its place. The body limit of 13,107,200 bytes is 12.5 MB, which is large enough for typical uploads and small enough to stop a trivial memory-exhaustion attempt. SecRequestBodyNoFilesLimit caps non-file bodies at 128 KB, which covers JSON APIs and form posts. The Coraza 3.8 announcement lists two known gaps you should plan around: multipart form fields do not count toward SecArgumentsLimit, and SecUploadFileLimit is parsed but not enforced. Enforce upload counts in your application or at the proxy until the project closes those items.
Step 7: Tune the CRS Settings File
CRS 4 uses two paranoia settings. blocking_paranoia_level controls which rules can block a request. detection_paranoia_level controls which rules run and log. Setting detection higher than blocking is the best trick in the whole rulebook, because you see what a stricter level would catch without blocking a single customer. Note that CRS 3 used a single paranoia_level variable, and copy-pasted snippets from older tutorials use that name.
sudo tee /etc/coraza/custom/10-crs-tuning.conf >/dev/null <<'EOF' # Which paranoia level can block, and which level only logs SecAction "id:900000,phase:1,nolog,pass,t:none,setvar:tx.blocking_paranoia_level=1" SecAction "id:900001,phase:1,nolog,pass,t:none,setvar:tx.detection_paranoia_level=2" # Anomaly score thresholds (CRS defaults) SecAction "id:900110,phase:1,nolog,pass,t:none,\ setvar:tx.inbound_anomaly_score_threshold=5,\ setvar:tx.outbound_anomaly_score_threshold=4" EOF
With the settings above, the WAF logs rules from level 2 but only blocks on level 1. A threshold of 5 means a single critical match (worth 5 points) blocks a request, while two warning-level matches (3 points each) also cross the line. That is the CRS default, and lowering the number makes the WAF stricter, not looser.
Step 8: Write the Caddyfile With the coraza_waf Directive
Now wire everything together. The global order line is mandatory. Caddy only runs handlers in a defined order, and a third-party directive has no default slot, so you must tell it to run the WAF first. The Include order inside the directives block matters just as much: engine config, CRS setup, your tuning, the CRS rules, then your after-CRS exclusions.
sudo mkdir -p /etc/caddy-waf sudo touch /etc/coraza/custom/before-crs.conf /etc/coraza/custom/after-crs.conf sudo tee /etc/caddy-waf/Caddyfile >/dev/null <<'EOF' { order coraza_waf first admin off } :8080 { log { output file /var/log/caddy-waf/access.log format json } coraza_waf { directives ` Include /etc/coraza/coraza.conf Include /etc/coraza/custom/00-overrides.conf Include /etc/coraza/crs-setup.conf Include /etc/coraza/custom/10-crs-tuning.conf Include /etc/coraza/custom/before-crs.conf Include /etc/coraza/crs/rules/*.conf Include /etc/coraza/custom/after-crs.conf ` } reverse_proxy 127.0.0.1:8081 } EOF sudo -u caddywaf caddy-coraza validate --config /etc/caddy-waf/Caddyfile --adapter caddyfile
Validation ends with Valid configuration. The first validation can take several seconds because Coraza parses and compiles every CRS regular expression. That is also where the memory sharing described earlier pays off. For production, replace :8080 with your domain name, and Caddy will request and renew a certificate on its own.
Step 9: Run the WAF as a systemd Service
Running the proxy by hand proves nothing about reboots. A systemd unit gives you restart-on-failure, log capture and sandboxing. The hardening lines below drop capabilities the proxy does not need and make most of the file system read-only. Ports below 1024 need one ambient capability, which the unit grants.
sudo tee /etc/systemd/system/caddy-waf.service >/dev/null <<'EOF' [Unit] Description=Caddy with Coraza WAF After=network-online.target demo-upstream.service Wants=network-online.target [Service] User=caddywaf Group=caddywaf ExecStart=/usr/local/bin/caddy-coraza run --config /etc/caddy-waf/Caddyfile --adapter caddyfile Restart=on-failure LimitNOFILE=1048576 AmbientCapabilities=CAP_NET_BIND_SERVICE NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/var/log/coraza /var/log/caddy-waf /var/lib/caddy-waf [Install] WantedBy=multi-user.target EOF sudo systemctl daemon-reload sudo systemctl enable --now caddy-waf systemctl is-active caddy-waf curl -si http://127.0.0.1:8080/ | head -n 5
Expected output is active followed by an HTTP/1.1 200 OK status line from the demo page. With admin off in the Caddyfile, caddy reload has no admin API to talk to, so use sudo systemctl restart caddy-waf after every config change. Restarts take a couple of seconds, and a request in flight during the restart gets a connection error, so schedule rule changes for a quiet window.
Step 10: Fire Test Attacks in Detection Mode
The engine is in DetectionOnly, so every attack below should return the demo page with a 200 status, while the audit log records what the rules saw. That split is the point. You prove the rules fire before they have any power to hurt real users. Use single quotes around URLs so your shell does not mangle the payloads.
# 1. SQL injection in a query string curl -s -o /dev/null -w "sqli %{http_code}\n" \ "http://127.0.0.1:8080/?id=1%27%20OR%20%271%27%3D%271" # 2. Cross-site scripting curl -s -o /dev/null -w "xss %{http_code}\n" \ "http://127.0.0.1:8080/?q=%3Cscript%3Ealert(1)%3C/script%3E" # 3. Path traversal curl -s -o /dev/null -w "lfi %{http_code}\n" \ "http://127.0.0.1:8080/?file=../../etc/passwd" # 4. Scanner user agent curl -s -o /dev/null -w "scan %{http_code}\n" -A "sqlmap/1.8" \ "http://127.0.0.1:8080/" # 5. Normal request curl -s -o /dev/null -w "clean %{http_code}\n" "http://127.0.0.1:8080/"
sqli 200 xss 200 lfi 200 scan 200 clean 200
All five return 200, which confirms detection-only mode is on. Now check that the log recorded the four attacks and left the clean request alone.
sudo wc -l /var/log/coraza/audit.log sudo tail -n 1 /var/log/coraza/audit.log | jq '.transaction.request.uri, [.messages[].data.id]'
If the log file has four new entries, the pipeline works end to end. Run the same payloads with a real security scanner once you are comfortable. Our guide to setting up OWASP ZAP shows how to aim an automated scan at a target like this one and compare its findings with what the WAF flagged.
Step 11: Read the Audit Log and Rank the Rules
Raw JSON is hard to scan by eye, so build two jq one-liners you will reuse for the life of the deployment. The first ranks the rules by how often they match. The second shows which URIs trigger a chosen rule. Together they turn a wall of text into a short tuning to-do list.
# Top rule IDs by hit count sudo jq -r '.messages[]? | "\(.data.id) \(.message)"' /var/log/coraza/audit.log \ | sort | uniq -c | sort -rn | head -n 15 # URIs that triggered a given rule (example: 942100) sudo jq -r 'select(any(.messages[]?; .data.id == 942100)) | .transaction.request.uri' \ /var/log/coraza/audit.log | sort | uniq -c | sort -rn | head
Sample output from our test run looks like the block below. Counts will differ on your machine, and your log will contain extra housekeeping rules, but the pattern holds: SQL injection, XSS and scanner rules at the top, then a final anomaly-evaluation rule that records the total score for each request.
2 942100 SQL Injection Attack Detected via libinjection 1 941100 XSS Attack Detected via libinjection 1 930100 Path Traversal Attack (/../) or (/.../) 1 913100 Found User-Agent associated with security scanner 4 949110 Inbound Anomaly Score Exceeded
That 949110 line is the one to watch in production. It fires whenever a request crosses the blocking threshold, so in detection mode it tells you exactly how many requests would have been blocked. If you see hundreds per day from normal browsing, do not switch to blocking yet. Tune first. If you ship logs to a SIEM, the comparison in our Wazuh vs Elastic vs Graylog piece helps you pick a destination for this JSON.
Step 12: Tune False Positives With Exclusions
Every real application trips a rule or two. A rich-text editor posts HTML, a search box accepts quotes, a webhook sends a signed blob that looks like encoded data. The right fix is a narrow exclusion that names the rule, the path and sometimes the parameter. The wrong fix is deleting a whole rule file. Keep exclusions in version control and add a comment saying who asked for it and why.
# /etc/coraza/custom/before-crs.conf (loads BEFORE the CRS rules) # Skip one SQLi rule for the CMS editor, only on the body field named "content" SecRule REQUEST_URI "@beginsWith /admin/editor" \ "id:10001,phase:1,pass,nolog,\ ctl:ruleRemoveTargetById=942100;ARGS:content" # Webhook receiver posts signed payloads, relax one rule for that path only SecRule REQUEST_URI "@streq /api/webhooks/payments" \ "id:10002,phase:1,pass,nolog,ctl:ruleRemoveById=920420"
# /etc/coraza/custom/after-crs.conf (loads AFTER the CRS rules) # Remove a rule globally. Use sparingly and document the reason. # 920350: Host header is a numeric IP address (we test by IP in staging) SecRuleRemoveById 920350
The split between the two files follows the CRS convention. Runtime exclusions that use ctl: actions must run before the rule they modify, so they live in the before file. Removals that use SecRuleRemoveById act at load time and must come after the rule exists. Pick custom rule IDs outside the ranges CRS uses. Restart the service, replay the traffic that caused the false positive and confirm the rule no longer appears in the log.
Step 13: Switch to Blocking Mode and Add Log Rotation
Once a few days of real traffic show a quiet 949110 count, flip the switch. Change one line, restart and replay the five test requests. Then add log rotation, because a JSON audit log on a busy site can fill a small disk faster than you expect.
sudo sed -i 's/^SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/coraza/custom/00-overrides.conf sudo systemctl restart caddy-waf curl -s -o /dev/null -w "sqli %{http_code}\n" "http://127.0.0.1:8080/?id=1%27%20OR%20%271%27%3D%271" curl -s -o /dev/null -w "clean %{http_code}\n" "http://127.0.0.1:8080/" sudo tee /etc/logrotate.d/coraza-waf >/dev/null <<'EOF' /var/log/coraza/*.log /var/log/caddy-waf/*.log { daily rotate 14 compress delaycompress missingok notifempty copytruncate } EOF sudo logrotate --debug /etc/logrotate.d/coraza-waf | tail -n 3
sqli 403 clean 200
The attack now gets a 403 and the clean request still gets a 200. You have a working, blocking WAF. The copytruncate option avoids the need to signal the proxy after rotation, at the small cost of possibly losing a few lines during the copy.
A Safe Rollout Plan From Detection to Blocking
Steps 10 through 13 compress a rollout that takes days in real life. The plan below stretches them into a schedule you can hand to a team. Its logic is simple: expose the WAF to real traffic while it has no power to block, fix what the logs show, then hand it the keys one slice at a time. Skipping straight to full blocking is the most common reason teams switch their WAF off after one bad morning.
The week-3 row relies on a per-request control. A rule in your before-CRS file can switch the engine to blocking for a single path while the rest of the site stays in detection mode. This gives you a real production test with a small blast radius.
# /etc/coraza/custom/before-crs.conf # Global mode stays DetectionOnly. Blocking applies only under /admin/login. SecRule REQUEST_URI "@beginsWith /admin/login" \ "id:10010,phase:1,pass,nolog,ctl:ruleEngine=On"
Treat the WAF host itself as a high-value target. It sees every request in clear text, including session cookies and form fields, so lock down who can log in, keep the OS patched and restrict read access to the audit log. The log can contain request bodies, which means passwords and personal data may land in it. Set a short retention period, as the logrotate rule in Step 13 does with 14 daily files, and check your privacy obligations before you ship those logs to a third-party service.
Plan capacity with a measurement instead of a guess. Run a load test through the proxy with the WAF block present, then again with it removed, and record both the median and the 95th percentile latency. Repeat after each change in paranoia level. Memory use after startup is the second number to record, since compiled rules sit in RAM for the life of the process. Those two baselines let you tell whether a later slowdown comes from the WAF or from your application.
Complete Working Project: One-Script Bootstrap and Smoke Test
Typing 13 steps by hand teaches you the pieces. Repeating them across five servers is where mistakes creep in. The script below rebuilds the whole stack from a clean Ubuntu host, and the second script proves it works. Save the first as bootstrap-coraza.sh, review it, then run it with sudo. It starts in detection mode, exactly like the manual flow, and it reads every version from variables at the top so an upgrade is a two-line edit.
#!/usr/bin/env bash # bootstrap-coraza.sh - Coraza WAF + CRS behind Caddy on Ubuntu set -euo pipefail CRS_VERSION="${CRS_VERSION:-4.25.2}" CORAZA_VERSION="${CORAZA_VERSION:-3.8.1}" UPSTREAM="${UPSTREAM:-127.0.0.1:8081}" LISTEN="${LISTEN:-:8080}" [ "$(id -u)" -eq 0 ] || { echo "run as root"; exit 1; } apt-get update -y apt-get install -y curl jq git tar ufw ca-certificates snapd id caddywaf >/dev/null 2>&1 || useradd --system --home /var/lib/caddy-waf \ --create-home --shell /usr/sbin/nologin caddywaf mkdir -p /etc/coraza/custom /etc/caddy-waf /var/log/coraza /var/log/caddy-waf /opt/crs chown caddywaf:caddywaf /var/log/coraza /var/log/caddy-waf # Build Caddy with the Coraza connector snap list go >/dev/null 2>&1 || snap install go --classic export PATH="$PATH:/snap/bin:/root/go/bin" export GOPATH=/root/go go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest cd /tmp xcaddy build --with github.com/corazawaf/coraza-caddy/v2 \ --with "github.com/corazawaf/coraza/v3@v${CORAZA_VERSION}" \ --output /tmp/caddy-coraza install -m 0755 /tmp/caddy-coraza /usr/local/bin/caddy-coraza # Pin CRS curl -fsSL -o /tmp/crs.tar.gz \ "https://github.com/coreruleset/coreruleset/archive/refs/tags/v${CRS_VERSION}.tar.gz" mkdir -p "/opt/crs/v${CRS_VERSION}" tar -xzf /tmp/crs.tar.gz -C "/opt/crs/v${CRS_VERSION}" --strip-components=1 ln -sfn "/opt/crs/v${CRS_VERSION}" /etc/coraza/crs cp -n /etc/coraza/crs/crs-setup.conf.example /etc/coraza/crs-setup.conf curl -fsSL -o /etc/coraza/coraza.conf \ "https://raw.githubusercontent.com/corazawaf/coraza/v${CORAZA_VERSION}/coraza.conf-recommended" cat > /etc/coraza/custom/00-overrides.conf <<'EOF' SecRuleEngine DetectionOnly SecRequestBodyAccess On SecRequestBodyLimit 13107200 SecRequestBodyNoFilesLimit 131072 SecRequestBodyLimitAction Reject SecResponseBodyAccess Off SecAuditEngine RelevantOnly SecAuditLogRelevantStatus "^(?:5|4(?!04))" SecAuditLogParts ABIJDEFHZ SecAuditLogType Serial SecAuditLogFormat JSON SecAuditLog /var/log/coraza/audit.log EOF cat > /etc/coraza/custom/10-crs-tuning.conf <<'EOF' SecAction "id:900000,phase:1,nolog,pass,t:none,setvar:tx.blocking_paranoia_level=1" SecAction "id:900001,phase:1,nolog,pass,t:none,setvar:tx.detection_paranoia_level=2" SecAction "id:900110,phase:1,nolog,pass,t:none,setvar:tx.inbound_anomaly_score_threshold=5,setvar:tx.outbound_anomaly_score_threshold=4" EOF touch /etc/coraza/custom/before-crs.conf /etc/coraza/custom/after-crs.conf chown -R root:caddywaf /etc/coraza; chmod -R g+rX /etc/coraza cat > /etc/caddy-waf/Caddyfile < /etc/systemd/system/caddy-waf.service <<'EOF' [Unit] Description=Caddy with Coraza WAF After=network-online.target Wants=network-online.target [Service] User=caddywaf Group=caddywaf ExecStart=/usr/local/bin/caddy-coraza run --config /etc/caddy-waf/Caddyfile --adapter caddyfile Restart=on-failure LimitNOFILE=1048576 AmbientCapabilities=CAP_NET_BIND_SERVICE NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/var/log/coraza /var/log/caddy-waf /var/lib/caddy-waf [Install] WantedBy=multi-user.target EOF sudo -u caddywaf /usr/local/bin/caddy-coraza validate --config /etc/caddy-waf/Caddyfile --adapter caddyfile systemctl daemon-reload systemctl enable --now caddy-waf echo "Coraza ${CORAZA_VERSION} + CRS ${CRS_VERSION} running in DetectionOnly mode on ${LISTEN}"
The script assumes your upstream is already listening on the address in UPSTREAM. If you followed Step 4, that is the demo service. Next, the smoke test. It sends the attack set, reads the expected status from the first argument and exits non-zero on any mismatch, which makes it easy to wire into a deploy pipeline or a cron job that pages you when the WAF stops doing its job.
#!/usr/bin/env bash # waf-smoke-test.sh [expected-attack-status] e.g. 200 in detection mode, 403 in blocking mode set -u BASE="${BASE:-http://127.0.0.1:8080}" ATTACK="${1:-403}" fail=0 check() { local name="$1" want="$2"; shift 2 local got got=$(curl -s -o /dev/null -w '%{http_code}' "$@") if [ "$got" = "$want" ]; then printf 'PASS %-6s %s\n' "$name" "$got" else printf 'FAIL %-6s got %s, wanted %s\n' "$name" "$got" "$want"; fail=1; fi } check clean 200 "$BASE/" check sqli "$ATTACK" "$BASE/?id=1%27%20OR%20%271%27%3D%271" check xss "$ATTACK" "$BASE/?q=%3Cscript%3Ealert(1)%3C/script%3E" check lfi "$ATTACK" "$BASE/?file=../../etc/passwd" check scan "$ATTACK" -A "sqlmap/1.8" "$BASE/" check union "$ATTACK" "$BASE/?id=1%20UNION%20SELECT%20NULL,username,password%20FROM%20users--" exit $fail
$ sudo bash bootstrap-coraza.sh Valid configuration Coraza 3.8.1 + CRS 4.25.2 running in DetectionOnly mode on :8080 $ bash waf-smoke-test.sh 200 PASS clean 200 PASS sqli 200 PASS xss 200 PASS lfi 200 PASS scan 200 PASS union 200 $ sudo sed -i 's/^SecRuleEngine DetectionOnly/SecRuleEngine On/' /etc/coraza/custom/00-overrides.conf $ sudo systemctl restart caddy-waf $ bash waf-smoke-test.sh 403 PASS clean 200 PASS sqli 403 PASS xss 403 PASS lfi 403 PASS scan 403 PASS union 403
The smoke test uses GET requests on purpose, because the demo upstream (Python’s built-in server) rejects POST requests on its own and would muddy the status codes. When you point the WAF at a real application, add POST checks that match its login and search forms. A bare single quote with a comment marker is a weak signal, so test with a stronger payload such as the UNION SELECT string above.
Common Pitfalls That Break a Coraza WAF Setup
These six mistakes account for most failed deployments we see described in community forums and project issue trackers. Read them before you go live.
Forgetting the global order line. Caddy refuses to start with an error about an unregistered handler order if order coraza_waf first is missing. The directive is a plugin, so Caddy has no default position for it.
Going straight to blocking. Even CRS at paranoia level 1 will trip on some real traffic, such as rich-text posts, JSON search filters and signed webhooks. Run detection mode first and read the anomaly-score entries.
Loading rules in the wrong order. The setup file must load before the CRS rules, your ctl: exclusions must load before the rule they change, and your removals must load after it. A wrong order fails silently.
Mixing CRS 3 and CRS 4 variable names. Snippets that set tx.paranoia_level belong to the old line, which lost support in Q3 2026. CRS 4 uses separate blocking and detection levels.
Leaving the origin reachable. If the port your real app listens on is open to the internet, attackers skip the WAF entirely. Bind upstreams to loopback or a private subnet, and firewall everything else.
Trusting the wrong client IP. Behind a CDN or load balancer, the audit log shows the proxy address unless you configure trusted proxies in Caddy. Rate-limit and blocklist logic built on that IP will then punish the wrong party.
A seventh trap deserves its own sentence. Treat the WAF as one layer, never the only one. Virtual patching buys time while you upgrade a vulnerable app, but it does not replace fixing the code. For the cloud-native equivalent of this layer, compare the approach in our AWS WAF setup on an ALB tutorial, which uses managed rule groups in place of CRS.
Troubleshooting Your Coraza WAF Setup
When something breaks, start with two commands: journalctl -u caddy-waf -n 50 --no-pager for startup errors and sudo tail -f /var/log/coraza/audit.log for rule activity. The table below maps the twelve most common symptoms to a cause and a fix.
For problem 4, the single most reliable check is to ask the live system what it is doing instead of reading config files. Send an attack and look at the status code. A 200 plus a new audit entry means detection mode. A 200 and no audit entry means the rules never ran, so look at the include order and the validation output from Step 8.
Advanced Tips for Production Coraza Deployments
Test rules in CI before they reach production
Your exclusion files are code, so give them tests. The CRS project ships its regression tests in the rule set’s tests directory, and its test runner, go-ftw, can replay them against any engine. Run the suite against a staging copy of your Caddy build whenever you change CRS versions or edit an exclusion. Add a handful of your own requests that must stay allowed, such as the exact JSON your mobile app sends at checkout. A failing test is cheaper than a failed order.
Upgrade CRS with the symlink and a canary
Unpack the new release beside the old one, point the staging server at it and let it run in detection mode for a week. Compare the count of anomaly-score entries between the two. If they match within noise, repoint the production symlink and restart. CRS 4.25.x is meant to receive quarterly point releases unless an urgent fix forces an earlier one, so schedule a recurring review each quarter instead of waiting for an alert. The October 2 coordinated release put the LTS and mainline lines out the same day, reportedly with fixes for three vulnerabilities and a file-upload bypass, which is a reminder that rulebook updates carry security fixes and should not sit unapplied.
Add an IP reputation layer in front of the WAF
A WAF judges requests one at a time and has no memory of a visitor who tried 400 payloads in five minutes. Pair it with a behavior-based blocker that bans repeat offenders at the firewall, so the offender never reaches the rules again. Our Wazuh vs Security Onion vs CrowdSec comparison explains where each tool fits. Forward the Coraza JSON audit log to whichever collector you choose, and alert on bursts of 949110 events from one address.
One more item for the roadmap. In January 2026 the CRS project introduced CRSLang, a YAML-based rule language that it plans to use in place of SecLang in the next major CRS release. Nothing in this tutorial breaks because of that, but keep your custom rules small and well commented so migration stays cheap. The wider set of defensive guides lives in our cybersecurity section.
Frequently Asked Questions About Coraza WAF Setup
Is Coraza a replacement for ModSecurity?
For many deployments, yes. Coraza reads the same SecLang rule language and runs the same OWASP Core Rule Set, and the CRS project lists ModSecurity v2, ModSecurity v3 and Coraza as supported engines. Behavior differs in places, so test your rules on Coraza before you cut over, and read the project’s compatibility notes for directives you rely on.
Which Coraza and CRS versions should I install in November 2026?
Use Coraza 3.8.1, published October 2, 2026, and CRS 4.25.2 LTS for production. CRS 4.30.0 is the newest mainline release and suits staging environments. Avoid CRS 3.3, whose support window ended in Q3 2026.
Does the Coraza WAF cost anything?
No. Coraza is an open-source OWASP project and CRS is an open-source rule set with no paid tiers. Your costs are the server, your time for tuning and any log storage you add.
How much overhead does the WAF add to each request?
It depends on paranoia level, body size and whether response inspection is on. We do not publish a latency figure because it would not transfer to your traffic. Measure it yourself by running the same load test with the WAF in detection mode and with the coraza_waf block removed, then compare the 95th percentile.
Can I use this setup with Nginx or HAProxy instead of Caddy?
Yes. The rule files, overrides, tuning and log analysis in Steps 5 through 13 stay the same. Only the build and the proxy config change. Coraza has connectors for Nginx, HAProxy and Wasm-based proxies, and each has its own version line, so pin the connector release as well as the engine.
How long should I stay in detection mode?
Run it for at least one full business cycle, which is usually a week. You want to see month-end reports, weekly batch jobs and any admin tasks that run on a schedule. Switch to blocking when the daily count of would-have-blocked events is close to zero for normal users.
Will a WAF stop every attack on my app?
No. It blocks known attack patterns and gives you time to patch, but it cannot fix flawed business logic, broken access control or stolen credentials. Keep patching, keep least-privilege access and keep scanning your own app.
![Coraza WAF Setup on Ubuntu: 13 Steps, 60 Min [2026]](https://news-pillar-bucket.s3.ap-south-1.amazonaws.com/wire/2026/11/28049322-9b17-4e49-9c3c-cb45ee35875b-large.webp)



