On this page
Introduction
Running a cheap VPS for a personal project, a small SaaS, or a client demo is great—until the first brute‑force attack hits. The problem isn’t just the noise; it’s the risk of false positives that lock out legitimate users and waste admin time.
In this guide I’ll show you how to build a zero‑false‑positive intrusion prevention pipeline on a single‑CPU Linux VPS using three open‑source tools that play nicely together:
| Tool | What it does | Why we need it |
|---|---|---|
| CrowdSec | Community‑driven log parser & threat intelligence engine | Detects attacks with low false‑positive rates thanks to crowd‑sourced scenarios |
| Fail2Ban | Traditional IPTables/Firewalld bans based on regex | Provides the actual packet‑filtering layer that CrowdSec can feed into |
| OpenResty (ngx_lua WAF) | Embedded Lua‑based web application firewall inside Nginx | Stops malicious HTTP traffic before it hits your app, reducing load on the OS‑level bans |
By the end you’ll have:
- Automatic bans that are validated by the CrowdSec community.
- Fail2Ban acting as the enforcement engine, keeping the rule set simple.
- An OpenResty WAF that drops bad HTTP requests early, cutting noise for the lower layers.
- A tuned configuration that keeps false positives at (practically) zero.
Note: All commands assume a Debian‑based VPS (Ubuntu 22.04 LTS). Adapt package names for Alpine, CentOS, etc.
Why combine CrowdSec, Fail2Ban, and OpenResty?
| Feature | CrowdSec | Fail2Ban | OpenResty WAF |
|---|---|---|---|
| Community threat intel | ✅ | ❌ | ❌ |
| Real‑time log parsing | ✅ | ✅ (custom regex) | ✅ (access logs) |
| Low‑resource footprint | ✅ (C‑based) | ✅ (Python) | ✅ (NGINX + Lua) |
| HTTP‑aware filtering | ❌ | ❌ | ✅ |
| Automatic ban propagation | ✅ (bouncer) | ✅ (jail) | ❌ |
| False‑positive mitigation | ✅ (scenario scoring) | ❌ (static regex) | ✅ (rule sets) |
CrowdSec gives you smart detection; Fail2Ban gives you fast, reliable packet drops; OpenResty WAF gives you application‑level protection. Together they form a defense‑in‑depth stack that is still light enough for a $5‑$10 VPS.
Prerequisites
| Item | Minimum |
|---|---|
| VPS OS | Ubuntu 22.04 (or Debian 11) |
| Root access | sudo privileges |
| Open ports | 22 (SSH), 80/443 (web) |
| Packages | curl, git, build-essential |
Make sure your system is up to date:
sudo apt update && sudo apt upgrade -y
Step 1 – Install and configure CrowdSec
1.1 Install the CrowdSec agent
# Add the official repository
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash
# Install the agent
sudo apt install crowdsec -y
The agent runs as a systemd service and starts parsing /var/log/auth.log, /var/log/nginx/access.log, etc., out of the box.
1.2 Add useful parsers and scenarios
CrowdSec ships with a huge catalogue. For a typical web VPS we’ll enable:
sudo cscli collections install crowdsecurity/sshd
sudo cscli collections install crowdsecurity/http-bad-bots
sudo cscli collections install crowdsecurity/nginx
Verify that the parsers are active:
sudo cscli parsers list | grep -E "sshd|nginx"
1.3 Test detection
Trigger a fake SSH brute‑force:
for i in {1..5}; do
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=1 invalid@localhost
done
Then check CrowdSec alerts:
sudo cscli alerts list
You should see an alert with a low confidence score (e.g., 0.3). CrowdSec only escalates to a ban when the score crosses the bouncers threshold, which we’ll configure next.
1.4 Configure the Fail2Ban bouncer
CrowdSec ships a Fail2Ban bouncer that writes IPs to a Fail2Ban jail. Install it:
sudo apt install crowdsec-firewall-bouncer-iptables -y
Enable and start the bouncer:
sudo systemctl enable crowdsec-firewall-bouncer
sudo systemctl start crowdsec-firewall-bouncer
Now CrowdSec will feed bans directly to iptables via Fail2Ban’s infrastructure. We still need a Fail2Ban jail to listen for those IPs.
Step 2 – Install Fail2Ban and link it with CrowdSec
2.1 Install Fail2Ban
sudo apt install fail2ban -y
2.2 Create a dedicated jail for CrowdSec
Create /etc/fail2ban/jail.d/crowdsec.conf:
[crowdsec]
enabled = true
filter = crowdsec
action = iptables[name=CrowdSec, port=all, protocol=all]
logpath = /var/log/fail2ban.log
maxretry = 1
bantime = 86400 ; 1 day
The filter crowdsec is provided by the crowdsec-firewall-bouncer-iptables package and simply reads the list of IPs that CrowdSec has marked for banning.
2.3 Reload Fail2Ban
sudo systemctl restart fail2ban
sudo fail2ban-client status crowdsec
You should see an empty list of banned IPs at this point.
2.4 Verify the whole loop
Run the same SSH brute‑force loop again, then:
sudo fail2ban-client status crowdsec
You should now see the offending IP in the Banned column, and iptables -L -n will show a rule inserted by Fail2Ban.
Step 3 – Install OpenResty and enable its built‑in WAF
OpenResty bundles Nginx with the LuaJIT engine, making it easy to run a lightweight WAF written in Lua.
3.1 Install OpenResty
# Add OpenResty repository
sudo apt install -y software-properties-common
sudo add-apt-repository -y ppa:openresty/ppa
sudo apt update
# Install
sudo apt install -y openresty
3.2 Basic server block
Create /usr/local/openresty/nginx/conf/sites-available/myapp.conf:
server {
listen 80;
server_name example.com;
# Enable the built‑in WAF
include /etc/openresty/waf/waf.conf;
location / {
proxy_pass http://127.0.0.1:8080; # your app upstream
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Enable the site:
sudo ln -s /usr/local/openresty/nginx/conf/sites-available/myapp.conf \
/usr/local/openresty/nginx/conf/sites-enabled/
sudo openresty -t && sudo systemctl restart openresty
3.3 Install the OpenResty WAF module
OpenResty ships a basic WAF under /etc/openresty/waf. If it’s missing, clone a popular open‑source rule set:
sudo git clone https://github.com/SpiderLabs/ModSecurity-nginx.git \
/etc/openresty/waf
For simplicity we’ll use a minimal rule file waf.conf:
# /etc/openresty/waf/waf.conf
lua_shared_dict waf_rules 10m;
init_by_lua_block {
local rules = {
-- Block common SQLi patterns
sql_injection = [[(?i)(union\s+select|select\s+.*\s+from|drop\s+table)]],
-- Block typical XSS payloads
xss = [[(?i)<script|javascript:|onerror|onload]],
-- Block known bad bots (user‑agent)
bad_bot = [[(?i)masscan|zgrab|nikto|sqlmap]],
}
for k,v in pairs(rules) do
ngx.shared.waf_rules:set(k, v)
end
}
access_by_lua_block {
local uri = ngx.var.request_uri
local ua = ngx.var.http_user_agent or ""
local args = ngx.var.args or ""
local function block(reason)
ngx.log(ngx.ERR, "WAF blocked request: ", reason)
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Simple pattern checks
for name, pattern in pairs(ngx.shared.waf_rules:get_keys()) do
local re = ngx.shared.waf_rules:get(name)
if re and (uri:find(re) or args:find(re) or ua:find(re)) then
block(name .. " pattern")
end
end
}
This Lua snippet loads a handful of regexes into a shared dictionary and checks every request in the access_by_lua_block. If any pattern matches, the request is denied with a 403.
3.4 Reload OpenResty
sudo openresty -t && sudo systemctl reload openresty
Now you have a web‑level firewall that catches obvious attacks before they even reach your application or the kernel.
Step 4 – Tuning thresholds to keep false positives at zero
4.1 CrowdSec scenario scores
CrowdSec scenarios have a score and a duration. The default bouncers threshold is 4. You can lower it if you want faster bans, but that raises false positives.
sudo cscli scenarios list | grep -E "score|duration"
If you notice legitimate traffic being banned, edit the scenario YAML under /etc/crowdsec/scenarios/ and reduce its score or increase duration.
4.2 Fail2Ban bantime
A long bantime is safe for obvious attacks, but for new IPs you may want a short trial ban (e.g., 10 min) before escalating to a day‑long ban.
# /etc/fail2ban/jail.d/crowdsec.conf (add)
bantime = 600
findtime = 600
maxretry = 1
Then use a second jail that reads from a higher‑confidence CrowdSec list and applies a longer ban.
4.3 OpenResty WAF rule weighting
Instead of a flat block, you can log suspicious requests and only block after N hits from the same IP:
access_by_lua_block {
local ip = ngx.var.remote_addr
local counter = ngx.shared.waf_rules:get(ip) or 0
counter = counter + 1
ngx.shared.waf_rules:set(ip, counter, 60) -- keep for 60 s
if counter > 5 then
ngx.log(ngx.ERR, "WAF: IP ", ip, " exceeded request threshold")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- existing pattern checks...
}
4.4 Monitoring
Enable CrowdSec’s metrics endpoint and feed it to Prometheus or just use cscli:
sudo cscli metrics list
Fail2Ban logs are in /var/log/fail2ban.log. OpenResty error logs (/usr/local/openresty/nginx/logs/error.log) will contain lines like WAF blocked request: sql_injection pattern.
Set up a simple cron that emails you when a new IP is banned:
# /etc/cron.daily/cs-f2b-report
#!/bin/bash
if sudo cscli alerts list -o json | jq -r '.[] | .source_ip' | grep -q .; then
sudo cscli alerts list -o json | mail -s "CrowdSec new alerts" [email protected]
fi
Make it executable:
sudo chmod +x /etc/cron.daily/cs-f2b-report
Step 5 – Testing the full pipeline
5.1 Simulate an HTTP attack
curl -A "sqlmap" "http://your-vps-ip/?id=1 UNION SELECT password FROM users"
You should see:
HTTP/1.1 403 Forbidden
OpenResty logs will contain WAF blocked request: sql_injection pattern.
5.2 Verify the IP is in the Fail2Ban jail
sudo fail2ban-client status crowdsec
You’ll see the attacking IP listed. Check iptables:
sudo iptables -L -n | grep <attacker_ip>
5.3 Ensure no legitimate request is blocked
Test a normal request:
curl -I http://your-vps-ip/
You should get 200 OK. If you notice a 403 on a legitimate call, revisit the Lua regexes or the CrowdSec scenario scores.
Checklist – Zero‑False‑Positive Intrusion Prevention
- System updated (
apt update && apt upgrade). - CrowdSec installed, parsers for SSH & Nginx enabled.
- CrowdSec Fail2Ban bouncer installed and running.
- Fail2Ban installed,
crowdsecjail created, and service restarted. - OpenResty installed and basic site config created.
- WAF Lua rules loaded (
/etc/openresty/waf/waf.conf). - Scenario scores tuned to avoid premature bans.
- Fail2Ban
bantime/findtimeset per risk appetite. - Logging enabled for CrowdSec, Fail2Ban, OpenResty.
- Simple monitoring (cron email or Prometheus) in place.
- Tested both malicious and normal traffic; no false positives observed.
FAQ
1. Do I really need all three components?
You can run any one of them alone, but each covers a different layer. CrowdSec gives you community‑driven detection, Fail2Ban enforces fast kernel‑level bans, and OpenResty blocks malicious HTTP before it reaches your app. Together they dramatically reduce noise and false positives.
2. How much RAM/CPU does this stack consume?
On a 1 vCPU, 512 MiB VPS the combined footprint stays under 80 MiB of RAM and negligible CPU (<2 %). The heavy lifting is done by CrowdSec’s compiled parsers; OpenResty’s Lua engine is lightweight.
3. Can I use nftables instead of iptables?
Yes. Install crowdsec-firewall-bouncer-nftables and adjust the Fail2Ban action to nftables. The rest of the workflow stays identical.
4. What if I host multiple domains on the same VPS?
Create separate OpenResty server blocks for each domain and point them to the same shared WAF config (include /etc/openresty/waf/waf.conf). CrowdSec will still parse all logs; you can add domain‑specific parsers if needed.
5. How often does CrowdSec update its threat database?
CrowdSec pulls updates from the community hub every 15 minutes by default. You can force a refresh with sudo cscli hub update.
Conclusion
By stitching CrowdSec, Fail2Ban, and OpenResty’s built‑in WAF together you get a low‑maintenance, high‑accuracy intrusion prevention system that works even on the cheapest VPS plans. The key is letting CrowdSec do the heavy detection, Fail2Ban enforce the bans, and OpenResty stop the bad HTTP traffic before it reaches your services.
Give it a try on your next low‑cost deployment and enjoy the peace of mind that comes from a truly zero‑false‑positive setup. For more hands‑on guides and real‑world tweaks, check out the tutorials at mahbuburriad.com.