Brute-force traffic wastes CPU, fills logs, and slows your store. Attackers pound public sites all day. Typical hits: /wp-login.php POST storms, /wp-admin/ scanners, xmlrpc.php spam, and 404 bursts looking for vulnerable files. Rate limiting helps, but you want to remove offenders at the network layer. Fail2ban with nftables gives you fast, persistent blocks that survive restarts.
This guide shows a production-ready Fail2ban setup that talks directly to nftables on Amazon Linux 2023, blocks on ports 80 and 443, and avoids conflicts with firewalld.
Common Gotchas We Will Avoid
- firewalld waking up after a reboot and stealing the nftables hook
- Bans going into a table that never gets applied to ports 80 and 443
- Thinking bans are active when they are only in the DB
- Removing firewalld packages and accidentally uninstalling fail2ban and nftables
What You Will Build
- A .local Fail2ban config that survives updates
- Site jails that block on http,https via nftables-multiport
- A recidive jail that drops offenders on all ports
- Safe checks that prevent lockouts
- Simple ban/unban commands for testing
Prerequisites
- Amazon Linux 2023 EC2 instance
- Nginx + PHP-FPM running
- Sudo access
- Your site access log path (example used here):
- /var/log/nginx/example.com-access.log
Tip: kernel updates can re-enable firewalld. We will prevent that and keep Fail2ban’s nft rules in charge.
π Step 1: Make Fail2ban settings persistent with .local
sudo cp /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.localEdit:
# /etc/fail2ban/fail2ban.local
[DEFAULT]
loglevel = INFO
logtarget = /var/log/fail2ban.log
dbfile = /var/lib/fail2ban/fail2ban.sqlite3
dbpurgeage = 90dRestart:
sudo systemctl restart fail2banπ¦ Step 2: Create site jails that use nftables on web ports
sudo vim /etc/fail2ban/jail.d/example.confPaste and adjust the log path and any allowlisted IPs:
# /etc/fail2ban/jail.d/example.conf
[DEFAULT]
banaction = nftables-multiport
port = http,https
ignoreip = 127.0.0.1/8 ::1
# Add fixed office IPs if needed. Additional IPs are added with a space between them:
# ignoreip = 127.0.0.1/8 ::1 203.0.113.50
logpath_site = /var/log/nginx/example.com-access.log
logpath_site = /var/log/nginx/example.com-access.log
[site-404-burst]
enabled = true
filter = site-404-burst
logpath = %(logpath_site)s
maxretry = 4
findtime = 90
bantime = 90d
action = nftables-multiport[name=site-404-burst, port="http,https", blocktype=drop]
[site-wp-admin-scan]
enabled = true
filter = site-wp-admin-scan
logpath = %(logpath_site)s
maxretry = 6
findtime = 120
bantime = 90d
action = nftables-multiport[name=site-wp-admin-scan, port="http,https", blocktype=drop]
[site-wp-login-post]
enabled = true
filter = site-wp-login-post
logpath = %(logpath_site)s
maxretry = 4
findtime = 300
bantime = 90d
action = nftables-multiport[name=site-wp-login-post, port="http,https", blocktype=drop]
[site-xmlrpc]
enabled = true
filter = site-xmlrpc
logpath = %(logpath_site)s
maxretry = 5
findtime = 60
bantime = 90d
action = nftables-multiport[name=site-xmlrpc, port="http,https", blocktype=drop]
# Repeat offenders lose all ports
[recidive]
enabled = true
filter = recidive
logpath = /var/log/fail2ban.log
maxretry = 10
findtime = 3600
bantime = 90d
action = nftables-allports[name=recidive, blocktype=drop]Why nftables-multiport? It generates a single f2b-chain with dport { 80, 443 } rules per jail. That is precisely what we want for web traffic, and it avoids juggling firewalld or ipset backends on AL2023.
π§© Step 3: Minimal Nginx filters
/etc/fail2ban/filter.d/site-404-burst.conf:
Catches bots that shotgun your site with fast 404s. The regex looks for repeated GET requests that return 404 on HTML pages, not static assets, so it will not trip on missing images or CSS. It is great for blocking scanners that try random paths like /wp-admin.php, /vendor/phpunit, or other junk. Pair it with a short findtime to nuke noisy sweeps quickly.
[Definition]
failregex = ^<HOST> - - \[.*\] "GET .* HTTP/.*" 404
ignoreregex =/etc/fail2ban/filter.d/site-wp-admin-scan.conf:
Targets directory crawlers poking around WordPress admin paths. It watches for repeated hits to /wp-admin and nearby endpoints that typically come from automated scanners, even if they bounce through 301s or 302s. Legit users usually land on the login page once, not dozens of times in a burst. This cuts off reconnaissance before it turns into a brute force.
[Definition]
failregex = ^<HOST> - - \[.*\] "(GET|POST) /wp-admin/.* HTTP/.*" (200|301|302|403|404)
ignoreregex =/etc/fail2ban/filter.d/site-wp-login-post.conf:
Stops brute force attempts against /wp-login.php by keying on POST requests, not harmless GETs. The filter matches multiple POSTs from the same IP within a window, regardless of whether Nginx returns 200 or 302 after the attempt. That keeps normal browsing untouched while hammer IPs get banned fast. Use a slightly longer findtime here to catch slow, sneaky attackers too.
[Definition]
failregex = ^<HOST> - - \[.*\] "POST /wp-login\.php HTTP/.*" (200|302|403|404)
ignoreregex =/etc/fail2ban/filter.d/site-xmlrpc.conf:
Blocks abuse of /xmlrpc.php, including multicall login floods and pingback probes. The regex focuses on POST requests to xmlrpc with patterns tied to authentication or high request rates. Most modern sites do not need xmlrpc at all, so banning offenders on this path is low risk and high value. This filter removes a popular attack lane without affecting regular users.
[Definition]
failregex = ^<HOST> - - \[.*\] "(GET|POST) /xmlrpc\.php HTTP/.*" (200|301|302|403|404)
ignoreregex =Validate and restart:
sudo fail2ban-client -t
sudo systemctl restart fail2banConfirm the jails:
fail2ban-client statusπ§― Step 4: Prevent firewalld from overriding nft
If you do not manage policies with firewalld, stop it so Fail2banβs nft rules stay in charge. Do not remove the firewalld RPM, as that can drag in needed Fail2ban packages along with it.
sudo systemctl stop firewalld
sudo systemctl disable firewalld
sudo systemctl mask firewalldDo not enable the nftables service unless you maintain your own base policy. Fail2ban will create and manage f2b-table on its own.
π Step 5: Verify that bans hit ports 80 and 443
Check the chain:
sudo nft list chain inet f2b-table f2b-chainYou want to see lines like:
tcp dport { 80, 443 } ip saddr @addr-set-site-wp-login-post dropSanity check one action template:
fail2ban-client get site-wp-login-post action nftables-multiport-site-wp-login-post actionstartIt should show dport { http,https }.
π§ͺ Step 6: Safe testing with your own IP
Find your public IP:
curl -s https://checkip.amazonaws.comTemporarily remove it from ignoreip if you allowlisted it. Trigger a few POSTs to /wp-login.php and confirm you get blocked.
Then unban yourself:
MYIP=203.0.113.99
sudo fail2ban-client set site-wp-login-post unbanip "$MYIP"You can also test a manual ban:
sudo fail2ban-client set site-wp-admin-scan banip 203.0.113.2π Step 7: After reboot checklist
# firewalld must stay off
systemctl is-enabled firewalld || true
systemctl is-active firewalld || true
# fail2ban should be enabled and running
systemctl is-enabled fail2ban
systemctl status fail2ban --no-pager
# f2b-table should exist again
sudo nft list tables | grep -i f2b || echo "f2b-table not created yet"If the table is missing, restart Fail2ban:
sudo systemctl restart fail2banπ§° Troubleshooting
Sites unreachable after reboot?
Mask firewalld and restart Fail2ban:
sudo systemctl mask firewalld
sudo systemctl restart fail2banBans not appearing in nft:
sudo fail2ban-client reload <jail>
sudo nft list chain inet f2b-table f2b-chainRestore old bans after backend switch:
awk -v jail="site-wp-login-post" '$0 ~ "\\["jail"\\] Ban " {print $NF}' /var/log/fail2ban.log \
| sort -u | while read ip; do sudo fail2ban-client set site-wp-login-post banip "$ip"; doneπ Wrap-Up
You now have a tidy Fail2ban + nftables setup on AL2023 that blocks the right traffic at the right ports without firewalld drama. Watch logs for a few days, tune thresholds, and enjoy quieter Nginx.





