Contents

CrowdSec vs Fail2ban: Modern Intrusion Prevention

One reads your logs and bans locally, the other reads your logs and asks the world what it already knows

Contents

Every machine with a routable address is under continuous, low-grade attack. That is the baseline condition of the internet, and it has been for twenty years. The traffic is automated, indiscriminate and cheap to generate, which is precisely why it never stops. A box I stood up on a Tuesday afternoon logged its first SSH login attempt for root inside four minutes of the DNS record propagating, and by the end of the week the auth log had eleven thousand failures in it from roughly nine hundred distinct addresses.

Nobody was after me. A botnet was working through address space alphabetically, or numerically, or however botnets go about their day. That is the important thing to hold onto, because it shapes the whole design problem. You are defending against a machine that will try eight passwords and move on, and then hand your address to a different machine that will try eight more next Thursday.

Fail2ban and CrowdSec both answer that problem by reading logs and dropping firewall rules on the offenders. I have run both in earnest for extended periods, and I have opinions about where each one belongs. I wrote about the philosophical split between them a few weeks back — local knowledge versus networked knowledge. This piece is the engineering follow-up: how they are actually built, what the configuration really looks like, and where each one falls down.

The architectural difference nobody mentions on the tin

Advertisement

Fail2ban is one Python daemon. It tails log files, runs each new line through a set of regular expressions called filters, and counts matches per source address inside a sliding window. Cross maxretry within findtime and it shells out to a firewall actioniptables, nftables, route, whatever you configured — to install a block for bantime. The bundle of a filter, a log path, and an action is a jail. That is the entire mental model, and its simplicity is the reason it has survived since 2004.

CrowdSec splits that single job across four pieces that talk over a local API:

  • Acquisition reads the log sources (files, journald, Docker socket, syslog).
  • Parsers turn raw log lines into structured events with typed fields — source IP, HTTP verb, target path, username.
  • Scenarios evaluate those structured events against leaky-bucket detection logic and emit alerts when a bucket overflows.
  • Bouncers are separate processes that read decisions from the API and enforce them.

The separation matters more than it sounds. Fail2ban’s regex both detects and identifies in one step, so a filter that matches slightly wrong produces a ban on a wrong address. CrowdSec parses first and detects second, so a scenario can reason about evt.Meta.http_status and evt.Parsed.request as fields rather than as capture groups. It also means CrowdSec’s detection engine has no idea how bans are enforced, and the enforcement layer has no idea why a decision exists. Swap cs-firewall-bouncer for the Caddy bouncer and the scenarios never notice.

And then the part CrowdSec is famous for: when a scenario fires, the alert is signed and shipped to the central Console, and in exchange your instance pulls down a community blocklist of addresses that misbehaved at other people’s installations. You get to block the botnet before it reaches you, on the strength of it having hit someone in Rotterdam an hour ago.

Fail2ban, configured properly

Most Fail2ban breakage traces back to editing jail.conf directly, which is a distribution-owned file that upgrades will overwrite. Everything goes in jail.local and jail.d/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# /etc/fail2ban/jail.local
[DEFAULT]
# Never ban yourself. This line has saved me twice.
ignoreip  = 127.0.0.1/8 ::1 192.168.1.0/24 10.0.0.0/8
backend   = systemd
banaction = nftables-multiport
bantime   = 1h
findtime  = 10m
maxretry  = 5

# Escalate repeat offenders instead of re-banning them for an hour forever
bantime.increment = true
bantime.factor    = 2
bantime.maxtime   = 5w

[sshd]
enabled  = true
mode     = aggressive
port     = ssh
maxretry = 3

[caddy-status]
enabled  = true
filter   = caddy-status
logpath  = /var/log/caddy/access.log
maxretry = 20
findtime = 5m
bantime  = 12h

Two things there earn their keep. bantime.increment turns a flat hour into geometric escalation, so the host that comes back every day for a fortnight ends up banned for five weeks; without it you are re-litigating the same address forever. And backend = systemd reads the journal directly rather than tailing a file, which sidesteps the entire category of bugs where log rotation moves the inode out from under the daemon.

Custom filters are unavoidable once you go past SSH. A filter is a regex file with a failregex line and a mandatory <HOST> token marking the address to ban:

1
2
3
4
5
# /etc/fail2ban/filter.d/caddy-status.conf
[Definition]
failregex = ^.*"client_ip":"<HOST>".*"status":(?:401|403|404).*$
ignoreregex = ^.*"uri":"/health".*$
datepattern = "ts":{EPOCH}

Test it before you enable it, always, because a bad regex is a self-inflicted denial of service:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
$ fail2ban-regex /var/log/caddy/access.log /etc/fail2ban/filter.d/caddy-status.conf
Running tests
=============
Use   failregex filter file : caddy-status, basedir: /etc/fail2ban
Use         log file : /var/log/caddy/access.log

Results
=======
Failregex: 1483 total
|-  #) [# of hits] regular expression
|   1) [1483] ^.*"client_ip":"<HOST>".*"status":(?:401|403|404).*$
`-
Date template hits:
|- [# of hits] date format
|  [12904] "ts":Epoch
`-
Lines: 12904 lines, 0 ignored, 1483 matched, 11421 missed

0 ignored and a healthy matched count is what you want. If missed is 100% of the file, the date pattern is usually the culprit rather than the failregex.

CrowdSec, configured properly

Advertisement

CrowdSec installs from its own repository and, on a machine it recognises, guesses your services and installs matching collections. That guessing is genuinely good, and it is also the first thing to verify rather than trust:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cscli hub list
COLLECTIONS
────────────────────────────────────────────────────────────
 Name                        📦 Status    Version
 crowdsecurity/linux         ✔️  enabled   0.20
 crowdsecurity/sshd          ✔️  enabled   0.5
 crowdsecurity/caddy         ✔️  enabled   0.3
 crowdsecurity/appsec-virtual-patching  ✔️  enabled  0.6

$ cscli metrics
Acquisition Metrics:
 Source            Lines read  Lines parsed  Lines unparsed  Lines poured to bucket
 file:/var/log/caddy/access.log  12904      12904          0               1483
 journalctl:sshd                  8891       8891          0               2210

Lines unparsed is the number to watch. A non-zero value there means a parser does not understand your log format, and every unparsed line is a blind spot. It is the CrowdSec equivalent of a Fail2ban regex that silently matches nothing, except you can actually see it.

Enforcement needs a bouncer, which is a separate package. The firewall bouncer maintains an nftables set and is the closest analogue to what Fail2ban does:

1
2
3
4
5
6
7
8
9
$ apt install crowdsec-firewall-bouncer-nftables
$ cscli bouncers list
Name                     IP Address  Valid  Last API pull
cs-firewall-bouncer-abc  127.0.0.1   ✔️     2025-07-03T08:12:41Z

$ cscli decisions list
ID     Source     Scope:Value      Reason                            Action  Duration
891    crowdsec   Ip:203.0.113.44  crowdsecurity/ssh-bf              ban     3h48m
892    CAPI       Ip:198.51.100.9  crowdsecurity/community-blocklist ban     71h02m

That second row is the entire pitch. 198.51.100.9 never touched my SSH port. It is banned because it attacked strangers, and the community API told me about it before it got around to my address space.

The AppSec component, added in CrowdSec 1.6, moves detection in front of the application entirely. Rather than reading a 403 out of a log after the request completed, the reverse proxy forwards the request to CrowdSec for a verdict first, and the request is blocked before your app ever parses it. It runs Coraza rules and ModSecurity-format rulesets, which makes it a lightweight WAF that happens to share a decision store with your IP bans. For anything self-hosted that speaks HTTP to the outside world, it closes the window between “the exploit ran” and “the log line got read”.

What banning actually buys you

Before the comparison gets any deeper, it is worth being blunt about the size of the prize, because both tools are routinely oversold.

Banning an address stops that address. The botnet driving the traffic has thousands of others and loses nothing of consequence when one is blocked; it moves on and a different node picks up the sweep next week. So the security value of a ban is small and temporary. Nobody is deterred. Nothing is deleted from anyone’s target list.

What you actually gain is threefold, and only one of the three is about security at all.

The first gain is log legibility. Eleven thousand failures a week make it genuinely impossible to notice the twelve interesting lines hiding among them. Ban the noise and the auth log becomes something a human can read, which means an anomaly stands a chance of being spotted. Every downstream security control you own gets better when its input stops being 99% garbage.

The second is resource protection. Each authentication attempt costs CPU, and password hashing is expensive by design. A sustained brute-force against a small VPS measurably degrades it. Dropping the traffic at the firewall costs a packet filter lookup instead of a bcrypt round.

The third, and the only genuine security gain, is narrowing the window on a mistake you have not found yet. Your SSH config is key-only today. It may not be after the next hurried change at midnight, and a service you install in six months may ship with a default credential you overlook. Rate-limiting means the automated sweep gets a handful of attempts rather than a million, so a temporary lapse stays survivable. That is defence in depth working exactly as intended: the ban is insurance against your own future error rather than a wall against a determined opponent.

Hold that framing and the rest of this comparison stays honest. Neither tool is protecting you from anyone competent.

Where each one hurts

Fail2ban is slow by construction. It reacts to log lines, which means the attack already happened. Five failed passwords land before ban six. For credential stuffing that is fine — the attacker was never getting in with a key-only SSH config anyway. For an exploit attempt that only needs one request, the ban arrives strictly after the damage.

Fail2ban’s regexes rot. Upstream changes a log format in a minor release and your filter quietly matches zero lines. There is no alarm. The jail sits there enabled = true, banning nobody, and you find out during an incident. I have been bitten by exactly this twice, which is why fail2ban-regex runs from a cron job on my boxes against a live log sample and shouts through my notification stack when the match count hits zero.

CrowdSec costs RAM and a network dependency. The agent plus a bouncer sits around 100–150 MB resident on a small box. It phones home. If you have a rule against outbound connections from your DMZ, the community blocklist is off the table and you have paid the complexity tax for the local-only half of the product.

CrowdSec’s community list is someone else’s judgement. You are importing bans generated by strangers’ scenarios on strangers’ logs. The false-positive rate is low and the consensus logic is reasonable, and you are still trusting a third party to decide who your firewall talks to. Whether that is acceptable is a question for your own threat model, and there is no universally right answer.

Both are useless against a competent, targeted attacker. Ten thousand distinct source addresses defeat rate-based banning entirely, and anyone spending real effort on you has ten thousand source addresses. These tools exist to delete the noise so you can see the signal.

Troubleshooting the failures you will actually hit

Advertisement

“Fail2ban says the jail is active but nothing gets banned.” Run fail2ban-client status sshd. If Currently failed: 0 while the log is visibly full of failures, the filter is not matching. Nine times in ten the datepattern is the culprit and the failregex is fine. Feed the log through fail2ban-regex and read the “Date template hits” block.

“CrowdSec raises alerts but nothing is blocked.” Alerts and decisions are different objects. cscli alerts list showing rows while cscli decisions list is empty means your scenario fired without a ban attached, usually because the profile in /etc/crowdsec/profiles.yaml filters it out. If decisions exist and traffic still arrives, the bouncer is the problem: cscli bouncers list and check Last API pull is recent.

“I locked myself out.” Both tools ship an escape hatch, and both are useless once you cannot reach the box. fail2ban-client set sshd unbanip 192.168.1.50 and cscli decisions delete --ip 192.168.1.50 undo a ban from a shell you no longer have. Put your management subnet in ignoreip and in /etc/crowdsec/parsers/s02-enrich/whitelists.yaml before you enable anything, and keep an out-of-band path to the console. This is not a hypothetical failure mode. I have driven to a machine over it.

“The bouncer wiped my custom firewall rules.” The nftables bouncer owns its own table, and it will happily conflict with a hand-rolled ruleset that assumes it owns everything. If you have written an nftables config by hand, read the bouncer’s table and chain priorities and make sure your drop policy is not shadowing the bouncer’s set lookup.

The verdict

Fail2ban belongs on any box where the exposed surface is SSH and you have already done the SSH hardening properly. It installs in one command, the config fits on a screen, it has no external dependencies, and it costs about 30 MB of RAM. For a VPS with key-only auth and one open port, the marginal value of anything more sophisticated is close to zero. Use it, set bantime.increment = true, and forget about it.

CrowdSec earns its keep the moment you are running a reverse proxy with real services behind it. The community blocklist is worth more than its RAM cost the first time it drops a scanner that would otherwise have found an unpatched endpoint, and the AppSec component turns it into a WAF you would otherwise be installing separately. The parse-then-detect architecture also means the thing degrades honestly — Lines unparsed tells you when you are blind, which Fail2ban never does.

What I actually run: CrowdSec on the edge box that terminates TLS and sees the internet, Fail2ban on the internal machines that only ever see SSH from the management subnet. That mix costs me two tools to learn instead of one, and I think that is the correct trade. If you want one tool everywhere and you host anything public, make it CrowdSec. If you want one tool everywhere and you host nothing public, Fail2ban is genuinely enough, and the hours you save go into something that matters more.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.