Our seven-part CRS 3.3 → 4.25 LTS migration series covers what changes and how to prepare. But reading about the changes and trusting them in production are two different things. If you are still running CRS 3 and hesitant to cut over, this post gives you a way to see CRS 4’s behavior against your own real traffic, live, before you change anything in production.
The idea: mirror, don’t switch
Keep CRS 3 answering production traffic exactly as it does today, and give CRS 4 a copy of the same requests to evaluate in parallel — without switching anything.
Docker Compose has no built-in way to send one request to two backends — that is not a networking feature, it is an application-layer concern. The standard tool for it is nginx’s mirror directive: it sends an async copy of every request to a shadow backend while the client only ever sees the response from the primary. If the shadow request fails, times out, or returns a 500, the client never knows.
That gives you a setup where:
- CRS 3 stays in front, in production, answering real clients exactly as it does today.
- CRS 4 sits in the shadows, receiving a copy of every request, so you can diff its ModSecurity audit log against CRS 3’s for real traffic — no synthetic test suite, no guessing which of your users’ edge cases might trip a new rule.
Apache has no equivalent to nginx’s mirror module, so the front door here is a small, plain nginx container — regardless of whether your CRS containers run Apache or Nginx underneath.
The compose file
Both CRS 3 and CRS 4 are now published as statically generated, stable lts tags on Docker Hub — 3.3-apache-lts and 4.25-apache-lts — so both containers are pulled straight from Docker Hub, no build step needed. Both front the same backend (swap BACKEND for your real application):
# Mirrors live traffic to CRS3 (primary, answers the client) and CRS4 (shadow,
# fire-and-forget) against the same backend, so you can diff ModSecurity audit
# logs between versions before upgrading.
#
# Both CRS3 and CRS4 are pulled from their published, stable "lts" tags - no
# build needed.
#
# Usage:
# docker compose up
# curl "http://localhost:8080/anything?id=1' OR '1'='1"
# docker compose logs crs-apache-v4 # what CRS4 would have done with the same request
#
# Point BACKEND at your own application to mirror real traffic instead of the
# httpbin placeholder.
services:
backend:
image: mccutchen/go-httpbin:v2.15.0
expose:
- "8080"
crs-apache-v3:
image: owasp/modsecurity-crs:3.3-apache-lts
environment:
BACKEND: http://backend:8080
expose:
- "8080"
crs-apache-v4:
image: owasp/modsecurity-crs:4.25-apache-lts
environment:
BACKEND: http://backend:8080
expose:
- "8080"
mirror:
image: nginx:1.27-alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- "8080:8080"
depends_on:
- crs-apache-v3
- crs-apache-v4
The nginx mirror config
worker_processes auto;
events {
worker_connections 1024;
}
http {
upstream primary {
server crs-apache-v3:8080;
}
upstream shadow {
server crs-apache-v4:8080;
}
server {
listen 8080;
location / {
mirror /mirror;
mirror_request_body on;
proxy_pass http://primary;
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;
}
# Shadow copy: response is discarded, client never sees it or its errors.
location = /mirror {
internal;
proxy_pass http://shadow$request_uri;
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;
}
}
}
mirror_request_body on copies the request body as well as headers, so CRS 4’s body-inspection rules see the same payload CRS 3 saw.
mirror duplicates the request itself, not just what CRS sees. That’s harmless in this compose setup, since both CRS containers point at the same stateless httpbin backend, which only echoes requests back. It stops being harmless the moment you swap BACKEND for your real application: a mirrored POST, PUT, or DELETE now reaches it twice, so any non-idempotent side effect (a charge, an email, a row insert) happens twice too. Restrict production mirroring to read-only/idempotent routes, or point the shadow path at a backend with no side effects (a staging replica, a stub) rather than the live one.
Trying it
docker compose up
curl "http://localhost:8080/anything?id=1' OR '1'='1"
docker compose logs crs-apache-v4 # what CRS4 would have done with the same request
The client gets CRS 3’s response (a 403 for the payload above, against crs-setup.conf defaults). docker compose logs crs-apache-v4 shows the same request went through CRS 4 as well — same anomaly score, same matched rules, same blocking decision, or a difference worth investigating before you touch production.
What this does and does not tell you
This surfaces rule-behavior differences — a request CRS 3 allowed that CRS 4 blocks, or vice versa — against your actual traffic shape, which is far more representative than any static test payload set. It does not validate performance under your production load (the shadow container adds real CPU and network cost proportional to your traffic volume, so size it and keep an eye on it) and it does not replace the configuration and plugin-migration steps covered in the rest of the series — read Part 2 through Part 7 for those. Think of mirroring as the empirical check that complements the reading, not a substitute for it.
Related pages:
- Migrating from CRS 3.3 to CRS 4.25 LTS — Part 7: Engine-Specific Notes
- Migrating from CRS 3.3 to CRS 4.25 LTS — Part 6: False Positive Tuning
- Migrating from CRS 3.3 to CRS 4.25 LTS — Part 5: Rule Changes
- Migrating from CRS 3.3 to CRS 4.25 LTS — Part 4: Anomaly Scoring and Reporting
- Migrating from CRS 3.3 to CRS 4.25 LTS — Part 3: The Plugin Architecture
- Migrating from CRS 3.3 to CRS 4.25 LTS — Part 2: Configuration
- Migrating from CRS 3.3 to CRS 4.25 LTS — Part 1: Overview
