Fail2Ban on a Busy VPS: Jail Regex Cost, Tarpit Behaviour and CPU You Didn’t Budget For

Fail2Ban installations are cheap to add and expensive to keep cheap. The package itself idles at a few megabytes, but each jail you enable spawns a log reader with a regex, and on a single-vCPU VPS a badly written filter can consume more CPU than the service it is protecting. This article is about the running cost of an existing fail2ban setup: which jails earn their cycles, why broad regexes blow up, how ban churn affects the kernel firewall, and where to verify with top and pidstat before you add another filter. If you’re still picking hardware, the VPS plans sized for small daemons like fail2ban matter here — one vCPU makes every percentage point visible.

Where the CPU Actually Goes

Fail2ban’s architecture is one thread per jail (by default). Each thread tails its logfile, runs the filter’s failregex line by line, and on a match calls into the ban action — usually iptables or nftables. Three costs follow from that: parsing volume (lines per second × regex cost), match rate (how often a ban fires), and action cost (how expensive the ban/unban syscall path is). On a quiet VPS the first dominates during log bursts; the third dominates during an attack.

systemctl status fail2ban --no-pager
fail2ban-client status
ps -o pid,pcpu,pmem,etime,cmd -C fail2ban-server
pidstat -t -p $(pgrep -f fail2ban-server) 5 12

pidstat -t breaks the number out per thread, which is the only way to see which jail is burning cycles. The main thread stays near zero; a jail thread at 8% of a single core is one eighth of your entire machine.

Log Parsing Cost per Jail

Measure it directly instead of guessing. fail2ban-regex runs your filter against a sample log and prints timing and match counts:

fail2ban-regex /var/log/auth.log /etc/fail2ban/filter.d/sshd.conf --print-all-matched

Expected output shape on a 50 MB auth log:

Lines: 214833 lines, 0 ignored, 1842 matched, 212991 missed
Date template hits: 214833
Success, the total number of match is 1842

Performance:
============
  Hits: 1842  [.. ]
  Misses: 212991
  ...
  Total: 3.812 s   (56,350 lines/s)

56k lines/sec on a 1 vCPU box sounds fine until you note that auth.log during an SSH brute-force campaign can grow at 2,000 lines/sec across a handful of IPs. That’s 3.5% of the core just to read the file — acceptable. Now try a filter with .* in the middle of the regex against the same log and the throughput drops by an order of magnitude. The fix is narrowing the regex to the literal prefix the daemon actually emits.

How Broad Regexes Blow Up

Fail2ban’s filter regexes are Python re patterns matched against each line, anchored at the start by default. The catastrophic patterns are the ones with nested quantifiers or a wildcard prefix:

# Bad: matches almost every line, then fails at the end
failregex = ^.*Failed password.* from <HOST>

# Better: anchor to the literal the daemon emits first
failregex = ^Failed password for (?:invalid user )?\S+ from <HOST> port \d+ ssh2$

# For journald-backed units, filter at the source
backend = systemd
journalmatch = _SYSTEMD_UNIT=sshd.service

The bad line does not just match more — it forces the regex engine to scan the whole line before failing, on every line, including the 200,000 that will never match. The better form fails in the first token. On a busy auth log that is the difference between 2% and 20% of a core. Add journalmatch and the kernel hands you only the relevant records, removing most of the parse cost entirely.

Ban Churn: The iptables Cost Nobody Budgets

Every ban and unban writes a rule. With default iptables and a linear ruleset, each insert is O(n) in the number of existing rules, and a distributed SSH attack can produce hundreds of rules in minutes. The visible symptom is not fail2ban CPU — it’s ksoftirqd and packet processing time on the forwarding path.

iptables -L f2b-sshd -n | wc -l
nft list set inet f2b-table addr-set-sshd 2>/dev/null | head
ipset list f2b-sshd 2>/dev/null | grep -c '^[0-9]'

watch -n2 'iptables -L -n | wc -l'

If the rule count climbs past a few thousand, switch the action to an ipset or nftables set:

# /etc/fail2ban/jail.d/defaults-debian.conf
[DEFAULT]
banaction = nftables-multiport
banaction_allports = nftables-allports

Set membership is O(1) per packet versus a linear walk, and adding a ban no longer rewrites a chain. On a single vCPU serving HTTP, this change is usually worth more than any filter tuning.

Why sshd Plus recidive Is Usually Enough

The default Debian/Ubuntu install ships a dozen jails: sshd, apache-auth, nginx-http-auth, postfix, proftpd, vsftpd and more. Most of them tail logfiles that do not exist on your box, which is harmless but wasteful — and a few of them, on a shared log, duplicate work. The pattern that covers real attacks with minimal cost:

[sshd]
enabled  = true
maxretry = 4
findtime = 10m
bantime  = 1h
backend  = systemd

[recidive]
enabled  = true
logpath  = /var/log/fail2ban.log
bantime  = 1w
findtime = 1d
maxretry = 3

sshd catches the first attempt; recidive escalates anyone who comes back after three bans to a week. That two-jail setup handles the overwhelming majority of automated SSH traffic. Add nginx-limit-req only if you actually see request-rate abuse in your access log, and verify with fail2ban-regex that it matches before enabling it — a jail that matches nothing is pure cost.

A common mistake is setting maxretry = 1 with a 1-hour bantime. That produces ban churn from legitimate users with a stale key, fills the ban list with real humans, and hides actual attackers among them. Four retries in ten minutes is enough to stop scripts and forgiving enough to keep people out of the ban list.

Tarpit and Blackhole: Behaviour and Downside

Adding iptables -j TARPIT (from xtables-addons) or -j DROP changes the failure mode. DROP is silent: the attacker’s SYN goes nowhere, they retry, and their timeout is minutes. TARPIT accepts the connection and then holds it open with a zero window, which keeps the attacker’s socket occupied — but each held socket consumes kernel memory and a conntrack entry on your box.

conntrack -C
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max

If nf_conntrack_count approaches 80% of max, you get nf_conntrack: table full, dropping packet in dmesg and legitimate connections start failing. A tarpit under a 10k-connection flood will fill the table on a 2 GB VPS long before the attacker gives up. For most single-server setups, REJECT --reject-with tcp-reset is the better trade: it ends the connection immediately, costs nothing to hold, and the attacker’s script moves on. Save the tarpit for hosts with conntrack headroom and a reason to waste attacker time.

Reading status Output Correctly

fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed: 7
|  |- Total failed:     4183
|  `- Journal matches:  _SYSTEMD_UNIT=sshd.service + _COMM=sshd
`- Actions
   |- Currently banned: 112
   |- Total banned:     967
   `- Banned IP list:   45.148.x.x 103.79.x.x ...

Two ratios matter. Total banned growing steadily is normal. Currently banned close to Total banned means bantime is long relative to attack volume and the list is growing without bound — check whether those IPs ever return. And if Total failed is climbing by thousands an hour while Total banned barely moves, your findtime is too short or maxretry too high for the actual pattern.

Tuning Checklist

  • Backend: systemd for anything journald logs; skips file parsing entirely.
  • Jails: sshd + recidive. Add one more only with a verified match rate.
  • Action: nftables or ipset once rule counts exceed a few hundred.
  • findtime: 10 minutes for SSH; long enough to catch a scripted retry, short enough to avoid banning humans.
  • bantime: 1 hour first tier, 1 week for recidive. Incremental bantime.increment = true with bantime.factor = 2 works well.
  • ignoreip: your own admin IPs and monitoring, or you will lock yourself out during an upgrade.

Cost, not features, is the constraint on a single vCPU. Two jails, a journald backend and a set-based ban action will sit under 1% of a core while stopping the same traffic that a dozen file-tailing jails handle at ten times the cost. Measure before and after with pidstat -t and keep the numbers — if the VPS you’re on shows the jail thread above 5% of a core at idle, something in the filter or the action is wrong.

Leave a Reply