🔑 Key Insights
✦ AI·GENThe author walks through swapping his server's defences from fail2ban to CrowdSec, written up as a guide you can follow. It didn't start with a breach but with noise: scanners hammering /wp-login.php and /.env all day, flooding his monitoring alerts. fail2ban had already banned 614 IPs, but he noticed the adversary simply rotates IPs, so banning your way down that road has no end. The data later confirmed it — the top seven source networks are almost all cloud datacentres like Azure, DigitalOcean and Alibaba Cloud, where spinning up a fresh VM costs far less than banning one IP. The article gives this machine's real six-day figures: 1,619 attacks detected and 25.22k IPs blocked at the firewall, of which the community blocklist supplies three thousand times more than his own engine catches. The three setup pitfalls form a single causal chain (apt pulling an ancient build from ESM, then a major-version upgrade relocating the hub, leaving broken symlinks and a 9% parse rate), with the cscli explain diagnostic and the "metrics are cumulative" trap included. The second half is the most relatable part: three self-inflicted lockouts, the best of which had his own nginx rate limiter return 429 while CrowdSec banned him for producing it — two protections chaining together to bite their owner. It closes with the Discord unblock bot, where the container can only drop request files and a root watcher on the host runs cscli, and which works at all because the bot dials outward, so a blocked IP never stops it receiving commands.
It didn't start with a breach. It started with noise.
My nginx feeds , and anything unusual goes to Discord. It looks like this, and it even pastes the current Top IP and Top Path list along with the alert:
The diagnostics are generous. The problem was that it fired hundreds to thousands of times a day, and almost none of it meant my services were in trouble. It was scanners hammering /wp-login.php, /.env and every .php endpoint they know, generating 4xx. My site has no PHP at all, but they don't know that and they don't care. They just keep knocking.
(That particular alert is one of the rare genuine ones: /api/spotify/audio-features was hit 9,968 times in five minutes, and that was my own code stuck in a loop. I redacted the IPs, since those are my own egress addresses.)
The machine already had fail2ban on it, and the numbers looked good:
614 IPs banned sounds useful. But staring at that stream of notifications, one thing struck me: they keep changing IP. Ban one and the next arrives on a different line. Does banning your way down this road have an end at all?
This is the full record of replacing it with CrowdSec: why it suits this situation better than fail2ban, the three pitfalls I hit during setup, what this machine actually blocked over six days, and the three times I locked myself out (the third is the dumbest and the most worth writing down). It ends with the Discord unblock bot I built, because when your own defences shut you out, SSH is exactly the thing you can't reach.
What this machine looks like#
The pitfalls and the self-inflicted bans both depend on the architecture, so let me set the scene first.
A few points that matter later:
- Everything runs on one physical machine, on a home fixed line with a static IP, with the router forwarding 80/443 in. No cloud load balancer, and no second box to take over when something breaks.
- Blocking happens at nftables, which is upstream of nginx. A blocked request never even reaches the access log, and from your side it looks like a silent timeout rather than a refused connection. That positional relationship underpins every diagnosis later on.
- The CrowdSec engine and the bouncer are separate pieces: the engine reads nginx's log and decides, the bouncer enforces at the firewall. The two dashed lines in the sketch are that loop.
- A dozen subdomains sit behind nginx: the blog, the NAS, monitoring, MQTT, an assortment of small tools. A rate limit or an error in any one of them feeds the same access log, and therefore feeds CrowdSec.
One more thing that matters here: my main domain runs Cloudflare grey-cloud (DNS only, traffic goes straight to my house), but another admin points one of his subdomains at me through the orange cloud, and on that path the source IP is a Cloudflare node. That fact bit me once in the cross-monitoring article, written up here:
Why not fail2ban#
fail2ban isn't broken. It does its job exactly as advertised. The issue is its model: read your own log, accumulate your own failure counts, ban the IPs you personally have seen.
Against an adversary that rotates IPs, that's whack-a-mole. Every new IP is brand new to you and has to climb from zero to the threshold before it gets banned, and by then it has finished its sweep.
CrowdSec changes the premise: bad IPs other people have already met, you get to use directly.
| fail2ban | CrowdSec | |
|---|---|---|
| Basis for the decision | Local log only | Local log plus a global community blocklist |
| An IP you've never seen | Starts from zero, gets one free sweep | Blocked immediately if someone else flagged it |
| Enforcement | Edits iptables itself | Handed to a bouncer (nftables / nginx / Cloudflare, swappable) |
| Rule source | Regexes you write | Community rules from the Hub, including a lot of CVE scenarios |
That third row is a real architectural difference: CrowdSec splits detection from enforcement. The engine only decides who should be blocked; a separate bouncer touches the firewall. That split becomes important later.
One more thing was decisive for me: it's self-hosted. The other option at the time was putting the site behind Cloudflare and letting it absorb the scanning, but that means handing my traffic to someone else's proxy. CrowdSec reads the log locally and enforces on the local firewall, leaving my existing architecture untouched.
NOTE
The community blocklist is on by default
Installation runs Registering to CAPI (Central API) automatically, so that shared blocklist starts protecting you immediately with no extra configuration. cscli console enroll is optional and gives you a dashboard plus larger subscription lists.
Three pitfalls, all from one root cause#
Installing CrowdSec is two lines:
curl -s https://install.crowdsec.net | sudo sh
sudo apt install -y crowdsec
sudo apt install -y crowdsec-firewall-bouncer-nftables # Ubuntu 24.04 defaults to nftablesThen I lost an entire evening, and all three pitfalls were one domino knocking over the next.
The official script does add the packagecloud repo, but apt install crowdsec actually downloaded from esm.ubuntu.com. holds a frozen 1.4.6, while packagecloud is on 1.7.x.
Nothing errors at install time. You have to run cscli version to discover the version is wrong.
In fairness I still find this design maddening. ESM's intent is to extend security maintenance for third-party packages, but it freezes the version at some point in time and then ranks ahead of the official repo you just added, and the result is "you think you installed the latest." After this episode I gave it serious thought: if all you want is a clean server, Debian probably springs far fewer of these surprises than Ubuntu LTS.
Having found the version was old, the natural move is to upgrade. And between 1.4.6 and 1.7.8, the hub data relocated:
old /var/lib/crowdsec/hub/...
new /etc/crowdsec/hub/...The pile of symlinks left by the old version all pointed at paths that no longer exist, leaving broken links. That string of Ignoring file ... no such file or directory lines during install is exactly this, but it's buried in dozens of lines of output and slides past as noise.
Broken symlinks meant parsers like nginx-logs were never loaded at all. The symptom is that cscli metrics looks like it's reading the log while the parse rate sits at 9%:
Source Lines read Lines parsed
file:/var/log/nginx/access.log 41 3It can read the file but it can't understand the contents. Not understanding means no scenario ever fires, and no scenario firing means nobody ever gets banned. The service is green on the surface and completely idle underneath.
The fix is to clear the broken links and reinstall the rule sets:
sudo find /etc/crowdsec -xtype l -delete # deletes only symlinks whose target doesn't exist
sudo cscli hub update
sudo cscli collections install \
crowdsecurity/nginx crowdsecurity/sshd crowdsecurity/linux \
crowdsecurity/whitelist-good-actors --force
sudo systemctl reload crowdsecTIP
The lesson: pin packagecloud and install 1.7.x directly
These three pitfalls form one causal chain with a single source: step one installed the ESM version. Run cscli version and check the number right after installing, and you skip the entire rest of it.
Two diagnostics that are easy to misread#
cscli metrics counters are cumulative. I spent a while thinking the parse rate hadn't improved after a fix, when newly parsed lines were simply being diluted by the pile of earlier failures. reload does not clear the counters; only restart does. To see the true post-fix ratio, restart and let it accumulate again.
Use cscli explain for a definitive answer. It prints the parser chain a single log line actually travelled, so whichever stage failed is immediately obvious:
sudo cscli explain --file /var/log/nginx/access.log --type nginx | tail -25Once fixed you should see crowdsecurity/nginx-logs pass in green and flow down into scenarios, instead of nothing but parser failures.
Keep only one SSH source#
One more I tuned myself. SSH events can be read from /var/log/auth.log or from journalctl, and the two are two sources for the same set of events. Collecting both counts every failed login twice, which fires scenarios earlier than the threshold you configured and inflates the alert count.
Measured, both yielded 261 events, but auth.log needed 10.13k lines read to get there while journalctl needed only 330. So my acquis.yaml keeps journalctl alone:
source: journalctl
journalctl_filter:
- "_SYSTEMD_UNIT=ssh.service"
labels:
type: syslogSix days, 1,619 attacks#
These are this machine's real numbers over the last six days (full cscli alerts list statistics):
Broken down by attack type:
| Scenario | Count | What it's after |
|---|---|---|
ssh-time-based-bf | 978 | Slow SSH brute force |
http-probing | 144 | Blind endpoint probing |
ssh-time-based-bf_user-enum | 121 | Guessing usernames |
http-sensitive-files | 44 | Hunting for .env, .git and similar |
http-bad-user-agent | 39 | Known scanner user agents |
http-wordpress-scan | 36 | Looking for WordPress holes |
http-admin-interface-probing | 36 | Looking for admin login pages |
http-cve-2021-41773 | 33 | Apache path traversal |
The long tail is a parade of CVE probes: CVE-2017-9841 (PHPUnit RCE), http-cve-2021-42013, CVE-2022-41082 (Exchange), thinkphp-cve-2018-20062, netgear_rce, fortinet-cve-2018-13379, jira_cve-2021-26086. I run none of those, and they come knocking every day regardless.
The attacks don't come from "a bad guy's computer"#
The distribution of source AS numbers is the most interesting column of all:
| Source | Count |
|---|---|
| MICROSOFT-CORP-MSN-AS-BLOCK | 199 |
| Techoff Srv Limited | 191 |
| DIGITALOCEAN-ASN | 171 |
| Hangzhou Alibaba Advertising | 165 |
| Hetzner Online GmbH | 161 |
| WEB MASTER COLOMBIA SAS | 153 |
| Sai gon Postel Corporation | 137 |
The top seven are almost entirely cloud datacentres: Azure, DigitalOcean, Alibaba Cloud, Hetzner. The scanners run on VMs billed by the hour. Ban one and spinning up another takes seconds, on a different IP.
Which answers my original question straight from the data: on the ban-the-IP road, your opponent's costs are far lower than yours. And that's precisely where the community blocklist earns its keep, because that freshly spun-up VM has usually already hit somebody else before it reaches you.
IMPORTANT
The community blocklist stops three thousand times more than I catch myself
The bouncer section of cscli metrics separates the two sources cleanly:
Origin active_decisions
CAPI (community blocklist) 25.21k
crowdsec (security engine) 8My own engine had caught 8 at that moment. The community blocklist was supplying 25,210 simultaneously. Among cumulative ban decisions, http:exploit 17,249, ssh:bruteforce 6,632 and http:scan 2,894 all came from CAPI.
In other words, the bulk of this tool's value applies the instant you finish installing it, with no waiting for your own history to build up.
Not everything blocked is hostile#
One detail is worth being clear about. A ban list at one point contained 167.94.146.48, stopped by crowdsecurity/http-bad-user-agent. Looking it up, it belongs to Censys, a research organisation that surveys the internet, as does Shodan. They aren't trying to hit you; they're cataloguing the world's public services.
Whether you block them is your call, but it's better to know what you're blocking. Incidentally, well-behaved crawlers are not a worry: the whitelist-good-actors collection is installed by default, Google, Bing and the various SEO crawlers are all whitelisted, and it uses rather than trusting the User-Agent, so they can't be impersonated.
I locked myself out three times#
This is the part most worth remembering. A system that bans automatically will eventually ban you.
I moved SSH to key-only authentication and turned off passwords. But the dev box on my LAN kept retrying the old way and failing, so the ssh-bf scenario judged it a brute force attempt and the bouncer dropped that internal IP entirely.
The symptom was strange: nothing on the LAN could connect, while coming in from outside worked fine, because the external route arrives on a different source IP.
I was testing a file upload feature whose handler had a bug: uploading froze the entire machine. With the machine frozen, my computer started frantically reconnecting SSH and HTTPS. What CrowdSec saw was an IP hammering it in a short window, judged it an attack, and banned it.
The picture at that moment was: SSH from my phone worked, my computer couldn't connect to anything, and none of the websites would open. My phone was on mobile data, a different IP.
It happened again later the same day, and this mechanism deserves its own explanation. From cscli decisions list:
Ip:<my egress IP> crowdsecurity/nginx-req-limit-exceeded ban 6 eventsWhat the nginx-req-limit-exceeded scenario does is: see nginx return 429, and conclude that this IP is flooding. And that 429 came from my own nginx rate limiter.
So the loop looks like this:
Two of my own protective measures chained together and bit me.
Three signals let you tell immediately#
Being blocked by CrowdSec and having a service go down really do look different:
| Signal | What it means |
|---|---|
i/o timeout rather than connection refused | Packets are being silently discarded, which is the signature of a firewall DROP. A dead service gives you refused |
| Phone fine, computer entirely dead | Different source IPs, so the problem is with your line rather than the server |
| Even images and CSS won't load | The whole IP is blocked, rather than one API being broken |
When all three hold, you can jump almost straight to cscli decisions list and look for yourself.
The fix has two layers#
Use a whitelist parser for the LAN. Drop a parser into s02-enrich so all of RFC1918 can never be banned:
# /etc/crowdsec/parsers/s02-enrich/lan-whitelist.yaml
name: koimsurai/lan-whitelist
description: "Never ban LAN / RFC1918"
whitelist:
reason: "private LAN"
cidr:
- "192.168.0.0/16"
- "10.0.0.0/8"
- "172.16.0.0/12"That rule has let 16,037 events through so far, which gives you a sense of how annoying life would be without it.
Use an allowlist for public egress IPs. The LAN whitelist can't save you when you're out of the house, since your source is then a public IP:
sudo cscli allowlists create home-dev -d "my dev machine IPs"
sudo cscli allowlists add home-dev <your egress IP>
sudo cscli allowlists inspect home-devWARNING
An allowlist beats loosening scenarios one by one
nginx-req-limit-exceeded fires on any nginx 429, regardless of which site or which limit rule produced it. Adjusting limits individually is whack-a-mole; an allowlist immunises all of your IPs at once and breaks the loop outright.
Know the cost: an IP on the allowlist will never have a ban decision created for it at all. So put only genuinely trusted sources on it, and note that a residential dynamic IP changes on every reconnect, which makes adding it pointless.
Letting myself out via Discord#
The shared problem across all three incidents is this: to lift a ban, you first have to get in. And when I was blocked, SSH was precisely the thing I couldn't reach.
The fallback at the time was my phone. I connect with , which I like a lot under normal circumstances; it has everything you need. But typing sudo cscli decisions delete --ip ... one-handed on a touch keyboard while out somewhere, after first picking your own IP out of an entire table, is not what I'd call a good experience.
One more thing makes it worse: CrowdSec doesn't notify you about who it banned. Those notifications lines in profiles.yaml are all commented out unless you wire up the slack / http / email plugin yourself. So being blocked by your own system never announces itself. You simply discover you can't connect, and start guessing.
# /etc/crowdsec/profiles.yaml — this is the default, notifications all commented
decisions:
- type: ban
duration: 4h
# notifications:
# - slack_default
# - http_defaultSo I added two commands to my existing Discord bot, solving "look it up" and "lift it" together. But there's an architectural problem: the bot runs in a container while CrowdSec is a host service, and a container can't reach the host's cscli.
Mounting the docker socket or granting host root into the container would solve it, but that hands an internet-facing bot control of the entire machine. So I used a file queue instead:
The only thing the container can do is drop a request file into the queue. What actually executes cscli is a watcher running as root on the host, which reads the request, validates it, runs it, and writes the result back. Even if the bot were compromised, an attacker could only drop request files, restricted to a fixed set of actions, and would never gain control of CrowdSec.
The key lines of the watcher:
ALLOWLIST = "parole" # add to this allowlist right after unbanning
ALLOWLIST_TTL = "4h" # only 4 hours, never a permanent pass
def valid_ip(s):
try:
ipaddress.ip_address(s) # blocks command injection
return True
except ValueError:
return False
if action == "unblock":
ip = str(req.get("ip", ""))
if not valid_ip(ip):
return {"ok": False, "output": f"invalid ip: {ip}"}
rc, out = run(["decisions", "delete", "--ip", ip])
ensure_allowlist()
run(["allowlists", "add", ALLOWLIST, ip, "-e", ALLOWLIST_TTL])Three design details:
This string gets handed to subprocess. The list form avoids the shell, but an unvalidated field will cause trouble eventually. One line of ipaddress.ip_address() closes it.
If you only delete the ban decision, the behaviour that triggered it is often still going (you're still hammering refresh, say), and you get re-banned seconds later. So unbanning also adds the IP to an allowlist called parole, for four hours only rather than permanently.
The cog checks interaction.user.id == OWNER_ID, and when OWNER_ID isn't set the default is to deny everyone rather than allow everyone. Replies are always ephemeral, so the ban list never lingers in the channel.
Once built, /crowdsec_list lays the current ban list out on your phone, each entry with its IP, the scenario that triggered it, and the time remaining:
Once you've spotted your own entry, /crowdsec_unblock with the IP lifts it, and the reply tells you how long the parole runs:
Those IPs incidentally illustrate something: http-cve-2021-41773, http-wordpress-scan, http-sensitive-files are all the same handful from the earlier statistics, and looking up their AS numbers turns up Google Cloud and Azure again. Even the batch I grabbed as an illustration is still scanners running in cloud datacentres.
The thing that finally makes all of this work is quite simple: the bot dials out to Discord. Your IP being DROPped at the firewall blocks the inbound direction, and has no effect at all on the bot's existing outbound connection. So while you're locked out, it still receives your commands.
A web admin panel hosted on your own machine cannot do this, because you have to reach it to use it, and reaching it is exactly what you can't do. SSH from a phone does route around the block (mobile data is a different IP), but you still have to type full commands on a phone.
Back to the beginning: did Discord go quiet?#
It did, but not for the reason I had planned.
Alongside installing CrowdSec, I had also prepared a netdata alert-silencing file, intending to route the 4xx group to silent and keep only 5xx and "getting slower". It was written and the commands were ready.
Then I went and looked just now:
$ ls /etc/netdata/health.d/
(empty)
$ ls ~/Server/netdata-web-noise-silence.conf
-rw-rw-r-- 1 timo9378 timo9378 2922 netdata-web-noise-silence.confThat silencing file was never deployed. It's still sitting in my home directory. And I genuinely haven't been bothered by a 4xx alert in a long time.
The reason is that the 4xx went away by itself. Sampling 6,038 lines from the same access log today:
The scanners get dropped by the bouncer at the firewall before they ever reach nginx, so that 404 never appears in the log at all, the 4xx ratio naturally stays under the threshold, and the alert never fires.
This outcome is considerably better than what I had planned. Silencing covers the gauge; this is nothing actually happening. Had I deployed the silencing file first, netdata would now stay quiet even during a genuine scanning wave and I'd have lost a useful signal. As it stands, when it does fire there's usually something real, like that alert at the top of the article, which was my own Spotify API stuck in a loop.
TIP
Treat the source before you consider silencing When an alert is noisy, the first instinct is usually to adjust the threshold or silence it. But if the noise exists because a lot of bad requests really are arriving, removing the source resolves the alert as well, and preserves the alert's discriminating power. Silencing belongs to cases where the alert shouldn't exist in the first place.
What became of fail2ban#
Honestly, I never formally retired it. After CrowdSec went live I simply stopped paying attention, and only later discovered it had settled into an awkward state: the service isn't running, and the config files are still there.
$ systemctl is-active fail2ban
inactive
$ systemctl is-enabled fail2ban
disabledHow it stopped is a little funny: while adding a LAN whitelist I dropped a file into jail.d/, and that file made it fail to parse at boot, so it never came back up. I didn't notice, because CrowdSec was blocking throughout that period and there were no symptoms at all.
The lesson here isn't "fail2ban is bad". It's this: when two overlapping defences coexist, the real danger isn't a conflict, it's believing both are on watch. Had it been CrowdSec that stopped back then, I'd probably have taken just as long to find out. If you're switching, switch cleanly, and confirm the new one is genuinely running.
The one-page checklist#
Self-hosted CrowdSec checklist
- Run
cscli versionright after installing. Getting an old build from ESM produces no error message, but everything that follows starts there. - After any major-version upgrade, check for broken symlinks:
sudo find /etc/crowdsec -xtype l -delete, thencscli hub update+collections install --force. - Verify parsing with
cscli explainrather than trusting thecscli metricsratio, which is cumulative;reloadwon't clear it, onlyrestartwill. - Keep journalctl as the only SSH source; collecting auth.log as well counts every failure twice.
- Set up the LAN whitelist parser on day one, or an operation like disabling SSH passwords will easily lock you out from inside your own network.
- Put public egress IPs on an allowlist; the LAN whitelist can't help you when you're out of the house.
- Watch out for
nginx-req-limit-exceeded: your own 429s feed the banning system, so hammering refresh will ban you. - Recognising a self-inflicted block:
i/o timeoutrather than refused, phone fine while the computer isn't, even static files failing to load. - Prepare an unbanning route that doesn't depend on SSH, and it only works if the architecture dials outward.
- CrowdSec doesn't notify by default; the notifications in
profiles.yamlare commented out, so wire them up yourself if you want them. - For a noisy alert, treat the source before discussing silencing; blocking the scanners makes the 4xx disappear on its own, while silencing merely covers the gauge.
- If you're retiring the old one, retire it cleanly, rather than leaving a half-dead service that fools you into thinking you have redundancy.
- CrowdSec — official documentationdocs.crowdsec.net
- CrowdSec Hub — scenarios, parsers and collectionsapp.crowdsec.net/hub
- CrowdSec — firewall bouncer (nftables / iptables)GitHub
- fail2ban — the official projectGitHub
- netdata — health alert configurationlearn.netdata.cloud
- Censys — about the internet-wide scanner and how to opt outabout.censys.io
No comments yet
✨ Be the first to comment