UFW vs iptables-nft on Ubuntu 22: why ufw enable silently breaks fail2ban-defined chains

I had a server running fail2ban happily for two years. SSH brute-force IPs were getting banned, the f2b-sshd chain was active, all good. Then I ran ufw enable to add a quick firewall rule for an internal service — and within a minute, my ssh session disconnected from the box I was helping over Tailscale, even though Tailscale’s IP was in fail2ban’s allowlist. Turned out fail2ban had been working through chains that ufw enable reset.

This is a class of bug that bites lots of Ubuntu users on first contact with UFW. The two tools (UFW and fail2ban) both want to manage iptables/nftables. They don’t know about each other. The collision is silent.

What’s actually happening

On Ubuntu 22, iptables is a compatibility wrapper over nftables. UFW reads its rule files (/etc/ufw/*), constructs an iptables-flavoured ruleset, and pushes it into the kernel via the wrapper. fail2ban reads its jail config and does the same — it adds f2b-* chains and inserts JUMP rules from INPUT into them.

The collision happens at ufw enable:

  • UFW flushes the INPUT chain to start fresh.
  • UFW rebuilds INPUT from its own config — which doesn’t include the JUMP rules to f2b-*.
  • fail2ban’s chains still exist, but nothing routes traffic to them anymore.
  • fail2ban’s existing bans are still there, but new connections bypass them entirely. The fail2ban daemon thinks everything is fine; fail2ban-client status sshd still shows banned IPs; the bans just don’t do anything.

Worse: when fail2ban next tries to add or remove a ban, it reaches into the chain layout it expects (with the INPUT JUMP intact) and either errors out or partially reapplies — leaving you with rules that contradict each other.

Verifying the breakage

# See current INPUT
sudo iptables -L INPUT -n -v --line-numbers

# Look for f2b-* chain references
sudo iptables -L INPUT -n | grep f2b
# After ufw enable, this is empty — even though the chains exist:
sudo iptables -L | grep '^Chain f2b'

# fail2ban thinks it's fine
sudo fail2ban-client status sshd
# Status for the jail: sshd
# |- Filter
# |  |- Currently failed: 0
# |  `- Currently banned: 12  <-- but the bans don't apply

The “12 currently banned” with zero references in INPUT is the smoking gun. The fail2ban daemon believes its rules are live; the kernel disagrees.

Three ways to fix it

  • Restart fail2ban after enabling UFW. The simplest fix. fail2ban re-adds its JUMP rules to INPUT when it starts. Run sudo systemctl restart fail2ban after every ufw enable or ufw reload. Easy to forget — until it bites you again.
  • Tell fail2ban to use a different chain. Set chain = INPUT_direct in /etc/fail2ban/action.d/iptables-common.local. UFW provides this hook chain — it survives ufw enable. Robust, but the JUMP target depends on UFW being installed; if you uninstall UFW, fail2ban breaks.
  • Pick one tool. Don’t run both. The cleanest answer. UFW is fine for “block port 25 from the world.” It’s not fine as a coordination point with anything else. If you need fail2ban or CrowdSec, drop UFW and manage iptables directly (or via your IDS’s bouncer).

I went with option 3 on the box that bit me. UFW’s syntax is friendly, but the semantics it imposes on INPUT aren’t worth the daily “did I remember to restart fail2ban” tax.

If you must keep UFW + fail2ban together

Drop a systemd hook so fail2ban restarts after UFW reloads:

cat <<EOF | sudo tee /etc/systemd/system/ufw-fail2ban.path
[Unit]
Description=Watch UFW config for changes; restart fail2ban

[Path]
PathChanged=/etc/ufw/user.rules
PathChanged=/etc/ufw/user6.rules
Unit=fail2ban-restart-after-ufw.service

[Install]
WantedBy=multi-user.target
EOF

cat <<EOF | sudo tee /etc/systemd/system/fail2ban-restart-after-ufw.service
[Unit]
Description=Restart fail2ban after UFW config change
After=ufw.service

[Service]
Type=oneshot
ExecStart=/bin/systemctl restart fail2ban.service
EOF

sudo systemctl enable --now ufw-fail2ban.path

This watches UFW’s rule files; whenever ufw enable or ufw reload writes them, fail2ban gets restarted. Closes the silent-breakage window.

The “ufw before-rules.d” alternative

UFW reads files in /etc/ufw/before.rules as part of its INPUT construction. You can teach UFW about fail2ban’s chains by adding the JUMP there:

# Add to /etc/ufw/before.rules, just after the existing rules
-A ufw-before-input -j f2b-sshd

Now the fail2ban chain is referenced even after UFW rebuilds INPUT. The downside: this hard-codes the chain name. If you add a new fail2ban jail, you have to edit before.rules to match. The systemd-restart approach is more maintainable.

The takeaway

UFW is a frontend for iptables built on the assumption that nothing else writes iptables. fail2ban (and CrowdSec, and Docker, and Kubernetes’ kube-proxy) all violate that assumption. Run UFW alone, or run something else alone — but stacking UFW with anything that touches iptables is a slow trickle of “why doesn’t my firewall work” surprises.

If you find yourself adding hooks and fixups to make UFW play nicely with another firewall manager, the better answer is usually to drop UFW. The simpler your firewall toolchain, the fewer 2-AM debugging sessions you’ll have.

Cover photo: Cookiecutter on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.