fail2ban#

Why#

The firewall (nftables chapter) rate-limits traffic, but doesn’t remember repeated behavior over time: an IP scanning the server every few minutes stays under the instantaneous rate-limit threshold while being clearly malicious over the long run. fail2ban fills this gap by parsing application logs (nginx, SSH) to detect repeated patterns and ban offending IPs over a longer horizon.

This guide does not aim for exhaustiveness on security. The configuration below covers this project’s needs, not a complete intrusion-prevention policy. The Lynis chapter, later on, offers an audit method to go further.

Why fail2ban rather than CrowdSec (and similar tools)#

CrowdSec is today the most commonly cited modern alternative to fail2ban, so the question deserves a reasoned answer. Several reasons tipped the balance toward fail2ban on this particular server:

  • Independence. CrowdSec’s main appeal, its community blocklist, depends on a central service that every agent reports sightings to and receives an IP reputation back from: a permanent external network dependency, at odds with the spirit of this site (static, no external network call at runtime, see hugo.toml) and of this server, whose whole point is to run autonomously and auditably.
  • Attack surface. CrowdSec adds a resident daemon, a separate bouncer component to apply bans, and a local API (LAPI) that listens on a port by default: more services to harden and filter, on a machine that instead aims to minimize what runs continuously (the same logic as in the Munin chapter).
  • The actual need. This server faces a narrow, well-understood attack profile (HTTP scanning, SSH brute force), not the breadth of threats CrowdSec’s community threat intelligence is meant to cover. fail2ban, tuned with regexes on local logs, matches this scope precisely.
  • Full auditability. A fail2ban jail reads entirely from a local .conf file (regex, thresholds, action). CrowdSec’s scenarios, distributed via a community hub and a dedicated YAML DSL, add a layer of abstraction and a dependency on third-party content, less immediate to inspect in full.

This choice remains a personal call by the author, consistent with this project’s constraints (autonomy, minimal attack surface), not a definitive verdict on CrowdSec: a reader running several servers who values collaborative detection may well prefer CrowdSec, or any equivalent tool (Sagan, OSSEC/Wazuh on the HIDS side…). It is a trade-off to reconsider case by case, not a rule to copy without thinking.

How#

A dedicated nftables action#

fail2ban provisions its own nftables tables, isolated from the main configuration in the previous chapter:

cat > /etc/fail2ban/action.d/nftables-subnets.conf <<'EOF'
[Definition]
allowipv6 = auto

actionstart = nft add table ip fail2ban
              nft add chain ip fail2ban <name>-ip \{ type filter hook input priority -1 \; \}
              nft add set ip fail2ban <name>-ip-set \{ type ipv4_addr \; flags interval \; \}
              nft add rule ip fail2ban <name>-ip ip saddr @<name>-ip-set reject
              nft add table ip6 fail2ban
              nft add chain ip6 fail2ban <name>-ip6 \{ type filter hook input priority -1 \; \}
              nft add set ip6 fail2ban <name>-ip6-set \{ type ipv6_addr \; flags interval \; \}
              nft add rule ip6 fail2ban <name>-ip6 ip6 saddr @<name>-ip6-set reject

actionstop  = nft flush chain ip fail2ban <name>-ip
              nft delete chain ip fail2ban <name>-ip
              nft delete set ip fail2ban <name>-ip-set
              nft flush chain ip6 fail2ban <name>-ip6
              nft delete chain ip6 fail2ban <name>-ip6
              nft delete set ip6 fail2ban <name>-ip6-set

actioncheck =

# Ban by subnet, not just the single IP (/28 IPv4, /96 IPv6)
actionban   = if [ "<family>" = "inet6" ]; then nft add element ip6 fail2ban <name>-ip6-set \{ <ip>/96 \}; else nft add element ip fail2ban <name>-ip-set \{ <ip>/28 \}; fi
actionunban = if [ "<family>" = "inet6" ]; then nft delete element ip6 fail2ban <name>-ip6-set \{ <ip>/96 \}; else nft delete element ip fail2ban <name>-ip-set \{ <ip>/28 \}; fi

[Init]
name = default
EOF

priority -1 places this dedicated chain before the firewall’s main chain (nftables chapter, which uses the named priority filter, equivalent to 0): nftables base chain priorities are evaluated in increasing numeric order, so a fail2ban ban applies before the packet even reaches the general ruleset, whatever it contains.

Why ban an entire subnet rather than a single IP, and why these two specific sizes:

  • IPv4, /28 (16 addresses). Mass scanners (Censys, Shodan, ZGrab, Expanse…) don’t probe from a single IP: they run their machines on blocks they own, often allocations as small as a /28 or /29 at a hosting provider. Banning only the IP that tripped the filter leaves the door wide open for the next probe from the same block, seconds later. Observed in production on this server: a bit over a hundred individually banned IPs are enough to cover close to 2,000 addresses actually blocked once widened to /28, which empirically confirms this block-scanning pattern. /28 stays narrow enough to avoid spilling over into an entire /24 (256 addresses) that a hosting provider might have split among several unrelated customers.
  • IPv6, /96. The opposite problem applies. An IPv6 host trivially changes the last 64 bits of its address (RFC 4941 privacy extensions, or simply picking another address within its allocated prefix): banning a single /128 holds nobody back for more than a few minutes. The “natural” answer would be to ban the /64 typically assigned to a customer by their ISP, but a /64 is supposed to cover an entire household’s or company’s local network: banning it whole could punish hundreds of unrelated devices. /96 is a deliberate compromise: wide enough (2³² addresses, the size of the entire historical IPv4 space) to absorb a trivial rotation of the last 32 bits, but noticeably narrower than a full /64.

Filters and jails#

printf '%s\n' \
    '[Definition]' \
    'failregex = ^<HOST> - \S+ \[.*\] ".*" (444|400|405|418) \d+' \
    'ignoreregex =' \
    > /etc/fail2ban/filter.d/nginx-critical.conf

printf '%s\n' \
    '[Definition]' \
    'failregex = ^<HOST> - \S+ \[.*\] ".*" 4\d{2} \d+' \
    'ignoreregex =' \
    > /etc/fail2ban/filter.d/nginx-4xx.conf

cat > /etc/fail2ban/jail.d/nginx-rejets.conf <<'EOF'
[nginx-critical]
enabled  = true
filter   = nginx-critical
action   = nftables-subnets[name=nginx-critical]
maxretry = 1
findtime = 24h
bantime  = 30d
logpath  = /var/log/nginx/access.log

[nginx-4xx-global]
enabled  = true
port     = http,https
filter   = nginx-4xx
action   = nftables-subnets[name=nginx-4xx-global]
maxretry = 10
findtime = 60m
bantime  = 24h
logpath  = /var/log/nginx/access.log
EOF

Two distinct nginx jails, not one: nginx-critical bans immediately (maxretry = 1) on codes that reveal deliberate scanning (444, 405, 418, impossible in normal use), while nginx-4xx-global tolerates a wider threshold (maxretry = 10 over 60 minutes) to catch more diffuse abnormal behavior (repeated 404s, for instance) without banning a legitimate visitor who simply mistyped a URL once or twice. Ban duration follows the same severity logic: 30 days for a confirmed deliberate scan (nginx-critical), only 24h for merely suspicious behavior (nginx-4xx-global, sshd), enough to deter without risking penalizing a false positive for too long.

cat > /etc/fail2ban/jail.d/sshd.conf <<'EOF'
[sshd]
enabled  = true
backend  = systemd
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd
maxretry = 3
findtime = 60m
bantime  = 24h
EOF

Trusted subnets#

cat > /etc/fail2ban/jail.d/local-trust.conf <<'EOF'
[DEFAULT]
ignoreip = 127.0.0.0/8 ::1 fe80::/10 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 169.254.0.0/16
EOF

fail2ban-client reload

This list is only an example of what “trusted” means on this particular deployment, not a universal best practice. It assumes a single-household NAT home network, where no RFC 1918 address can legitimately arrive from the Internet (a premise detailed in the warning below). This premise doesn’t hold everywhere: on a shared network (co-hosting, shared hosting, a company LAN with unmanaged workstations or IoT devices), a private IP can very well belong to a compromised or hostile machine. In that kind of context, treating all of RFC 1918 as “trusted” amounts to disabling fail2ban’s protection for an entire class of potential attackers: better to restrict ignoreip to the actual, tightly controlled admin network rather than copy this list as-is.

The pipeline, diagrammed#

      ┌───────────────────┐                    ┌────────────────────┐
      │  /var/log/nginx/  │                    │  systemd-journald  │
      │    access.log     │                    │   (sshd events)    │
      └───────────────────┘                    └────────────────────┘
                │                                         │
                ▼                                         ▼
     ┌─────────────────────┐               ┌─────────────────────────────┐
     │      fail2ban       │               │          fail2ban           │
     │     nginx jail      │               │          sshd jail          │
     │  (444/400/405/418)  │               │  (authentication failures)  │
     └─────────────────────┘               └─────────────────────────────┘
                │                                         │
                └────────────────────┬────────────────────┘
                                     ▼
                       ┌───────────────────────────┐
                       │  nftables-subnets action  │
                       └───────────────────────────┘
                                     │
                                     ▼
                     ┌───────────────────────────────┐
                     │  table ip  fail2ban: set /28  │
                     │  table ip6 fail2ban: set /96  │
                     │           → reject            │
                     └───────────────────────────────┘

Pitfalls to avoid#

Without a properly configured ignoreip, a home router can get banned. A real incident: a home ISP box got banned by the nginx-critical filter following an automatic diagnostic request carrying a curl/... User-Agent, exactly the pattern the filter targets. Cascading consequence: banning the box broke both Let’s Encrypt’s ACME renewal (previous chapter) and the ISP’s built-in monitoring. Covering all RFC 1918 ranges in ignoreip, not just the explicitly used admin network, is a cheap precaution: no RFC 1918 IP should ever reach the server from the Internet through a properly configured NAT.

  • Testing an admin SSH command without ignoreip. A command like ssh root@server fails immediately (root login disabled, SSH hardening chapter) but still counts as an attempt, repeated a few times while debugging, it can self-ban the IP currently in use.
  • Forgetting fail2ban-client reload after adding new jails. On Debian, the fail2ban package auto-starts on install, often before this guide’s own jails are added. fail2ban-client status will only list sshd (the default jail) until a reload happens.
  • An unanticipated log timestamp format breaks the failregex. Debian’s default nginx log format includes the timestamp in brackets ([04/May/2026:18:04:30 +0200]), a regex written for a different format simply matches nothing, silently. Always validate with fail2ban-regex against a real log file before trusting a new filter.

Verify#

fail2ban-client status
# Should list: sshd, nginx-critical, nginx-4xx-global

fail2ban-client status nginx-critical
# Shows the number of currently banned IPs

# Validate a filter BEFORE deploying it
fail2ban-regex /var/log/nginx/access.log /etc/fail2ban/filter.d/nginx-critical.conf | grep -E "Failregex:|Lines:"
# "Failregex: N" with N > 0 if the pattern matches at least one real line

nft list table ip fail2ban | head -20
# Confirms the dedicated nftables tables are properly provisioned