How I Built an Adaptive Firewall Setup with UFW and Fail2ban (UFW+)
Learn how I built UFW+, an adaptive firewall setup that combines UFW, Fail2ban, and nftables to block floods, scans, and spoofed traffic efficiently.
In my earlier guide, I showed how to prevent SYN flood attacks using kernel tuning, a few UFW rules, and some basic Fail2ban jails to analyze UFW’s logs and block attackers.
That setup worked – it could stop simple SYN floods – but it had limits. It was reactive, narrow in scope, and mainly focused on controlling SYN packet rates.
Since then, I’ve built something much more powerful – a project I call UFW+.
UFW+ takes the same foundation and pushes it to the next level: an adaptive firewall setup that merges UFW, Fail2ban, and the performance of nftables into a cohesive, modern defense layer.
By moving away from iptables and leveraging nftables’ native sets feature, it blocks abusive IPs more efficiently – without adding thousands of individual DROP
rules – while Fail2ban maintains real-time ban lists directly inside nftables.
It doesn’t just rate-limit SYN packets anymore – it detects scans, spoofed traffic, and abusive connection floods in real time, then automatically bans the sources with smarter, escalating Fail2ban rules.
This guide walks you through how I built it, the reasoning behind every rule, and how you can deploy it to harden your own Linux server against modern network attacks.
If you’d like to be notified when updates are published, you can subscribe to the newsletter – I send an email whenever a new version of this setup or guide is released.
Author’s Note
This guide – or rather, UFW+ – focuses on protecting HTTP (port 80) and HTTPS (port 443) traffic, since those are the most common public entry points.
However, the same logic and rule patterns can be applied to any service port – the principles are universal.
I plan to extend UFW+ to cover additional ports and the UDP-based version of port 443 (QUIC) in the future.
When those improvements are implemented, this guide will be updated accordingly to include the new rules and configuration examples.
It’s worth noting that while smaller attacks can often be handled with the right firewall setup, large, distributed attacks are much harder to stop completely
In short, a Denial-of-Service (DoS) attack usually comes from one source, while a Distributed Denial-of-Service (DDoS) attack comes from many – making it far more difficult to block all of it at once.
That said, even basic protections can make your server a much harder target.
Even if your server provider (for example, Hetzner) offers some level of built-in DDoS protection, it often won’t catch the smaller, low-rate SYN floods that directly target your VPS.
That’s where UFW+ comes in – the setup you’ll learn in this guide focuses on server-side defenses that block and slow down those targeted attacks before they can cause real damage.
These layers don’t make you invincible, but they make your server resilient – and that’s the goal of everything you’ll build here.
Background: The Old Setup and Its Limits
In my previous setup, the goal was simple: stop SYN flood attacks.
At the time, I focused on the most common issue many people were struggling with – massive bursts of half-open TCP connections that could bring a production server down almost instantly.
A SYN flood is a type of DoS or DDoS attack where an attacker sends a flood of fake TCP connection requests (SYN packets) but never completes the handshake.
The old firewall setup worked: it used a few mangle table rules to drop suspicious packets, some UFW rate limits to slow down floods, and a basic Fail2ban jail that parsed UFW logs and temporarily blocked offending IPs.
That setup definitely helped – it could stop simple SYN floods and keep the server stable under light attack – but it also had limitations and design issues.
The rate limits were too strict, which sometimes blocked legitimate users. Logging wasn’t rate-limited either, so during heavy attacks the server logs could quickly fill up.
Fail2ban relied on adding one DROP
rule per banned IP through iptables, which didn’t scale well when you’re dealing with hundreds of attackers.
It also became clear that SYN floods weren’t the only problem. Some attackers avoided the SYN limit altogether by opening a large number of concurrent connections and keeping them idle – a form of connection flood that slowly consumed resources.
And because I didn’t yet whitelist Cloudflare’s edge IP ranges, some Cloudflare addresses were being blocked as well, which caused legitimate traffic to get filtered out.
Overall, the old setup worked, but it had rough edges that needed to be fixed. It was time to redesign it into something more efficient, scalable, and aware of real-world traffic patterns.
Design Goals and Architecture
When I started redesigning my old setup into UFW+, I wanted to move away from static, reactive defenses and toward an adaptive, scalable firewall setup.
The earlier approach worked for simple SYN floods, but it wasn’t sustainable under heavier or more diverse attacks.
So, I built UFW+ around three main design goals.
1. Smarter Packet Filtering at the Earliest Stage
The first step was improving packet sanity checks before traffic even reached higher firewall chains.
In the original setup, this stage only dropped invalid and non-SYN TCP packets:
-A PREROUTING -m conntrack --ctstate INVALID -j DROP
-A PREROUTING -p tcp -m tcp ! --tcp-flags FIN,SYN,RST,ACK SYN -m conntrack --ctstate NEW -j DROP
That worked, but left plenty of malformed packets untouched – so I expanded it into a full set of protocol sanity checks.
The idea was to make this stage act like a first-line scrubber, eliminating garbage packets such as:
- TCP packets with impossible or illegal flag combinations (like SYN+FIN or SYN+RST)
- NULL and Xmas scans
- Spoofed packets claiming private, loopback, or reserved IP sources
- TCP handshakes with invalid MSS values, which often signal malformed or probing traffic
By handling this at the PREROUTING
stage, the kernel drops malicious or malformed packets before they can consume connection-tracking resources – a lightweight way to protect the server from protocol abuse and spoofing.
2. Adaptive Connection and Rate Controls
In the earlier configuration, I used a fixed limit of 10 SYN packets per second per IP, with a small burst of 20.
There were no limits on total concurrent connections, so the firewall could only handle fast SYN floods – it didn’t recognize slower connection floods where attackers opened hundreds of idle sessions.
In short, it wasn’t adaptive. It applied the same rule to everyone, regardless of behavior, and it didn’t log efficiently either, which caused log spam during heavy attacks.
UFW+ changes that with three key improvements:
- Dynamic per-source SYN rate limiting:
Each source IP can open up to 30 new TCP connections per second (with a 60-packet burst). This allows for real traffic spikes while still throttling attacks. - Connection concurrency caps:
To counter slow connection floods – where attackers open many idle sessions instead of rapid SYN bursts – each IP is capped at 100 concurrent connections. - Rate-limited logging (1 log per minute per IP):
Both the connection-limit and SYN flood rules log at most once per minute per source. This prevents log flooding under attack and gives Fail2ban a clean, steady signal to react to.
These controls are layered: connection limits are evaluated first, then SYN flood checks apply only to new handshakes that pass through.
3. Fail2ban as an Adaptive Enforcement Layer
The biggest shift in UFW+ is how bans are applied and managed.
Previously, I used the classic iptables
action in Fail2ban:
action = iptables[type=allports, name=synflood, chain=fail2ban, protocol=tcp]
Each ban inserted a new DROP
rule directly into the firewall – fine for a few attackers, but not for hundreds.
Instead of inserting one DROP
rule per IP (which doesn’t scale well in iptables), Fail2ban now interacts directly with nftables sets – a native and efficient way to maintain large lists of banned IPs.
Each ban action simply adds the offending IP address to an nftables set referenced by a single rule, keeping the ruleset compact and performant.
The Fail2ban configuration itself became smarter and more dynamic:
- It now uses the systemd journal backend to read kernel-level UFW logs directly, improving accuracy and avoiding duplicate log parsing.
- Two specialized jails monitor different log patterns:
synflood
for excessive handshake attemptsconnlimit
for clients exceeding concurrent connection caps
- Both jails share the same adaptive banning policy:
3 strikes within 5 minutes triggers a 5-minute ban, which doubles on each repeat offense (up to 1 hour). This gives real users a margin of error while escalating punishment for persistent attackers. - A recidive jail tracks repeat offenders across days, applying bans that grow up to 3 weeks for chronic abuse.
Architecture Overview
To understand how all the pieces of UFW+ fit together, it helps to look at the overall flow of packet handling and rule evaluation.
Because nftables processes packets based on hook priorities (lower numbers are evaluated first), Fail2ban’s drop rule chain is inserted with a priority such that banned IPs are dropped before any UFW-based chain.
In practice, this happens even before the before.rules
file is evaluated, ensuring that once an IP is banned, it never reaches our UFW logic at all.
Here’s how the full traffic flow works:
┌───────────────────────────────────────────┐
│ Kernel (nftables mangle) │
│ → Drops invalid/spoofed/malformed packets │
└───────────────────────────────────────────┘
↓
┌───────────────────────────────────────────┐
│ Fail2ban nftables drop sets │
│ → Banned IPs are dropped immediately │
│ via high-priority hook (before UFW) │
└───────────────────────────────────────────┘
↓
┌───────────────────────────────────────────┐
│ UFW (nftables backend) │
│ → Cloudflare whitelist (first) │
│ → Per-IP connection concurrency limits │
│ → SYN flood rate limiting │
└───────────────────────────────────────────┘
↓
┌───────────────────────────────────────────┐
│ Fail2ban adaptive layer │
│ → Monitors kernel logs (systemd backend) │
│ → Updates nftables ban sets in real time │
│ → Escalates bans on repeat offenses │
└───────────────────────────────────────────┘
This order ensures that:
- Cloudflare traffic is trusted and bypasses analysis entirely.
- Connection caps are evaluated before SYN flood checks (since they act on already-open sessions).
- SYN flood limits handle new connection bursts.
- Fail2ban bans cut off attackers before UFW even runs.
Everything above the UFW layer (the mangle table and Fail2ban drop chain) happens early in packet processing, which keeps the firewall fast and efficient even under heavy attack.
Configuration Walkthrough
Now that you understand the reasoning and design behind UFW+, it’s time to put it into action.
This walkthrough takes you through every step of building the setup – from preparing your server and defining early packet filters to configuring adaptive UFW and Fail2ban rules and integrating them with nftables.
You can follow along on a fresh Ubuntu 24.04 LTS server (recommended), or adapt the steps for another Debian-based distribution.
Each section includes both the commands and the reasoning behind them – so you’ll know why every rule exists, not just how to copy it.
Server Preparation
Before adding any firewall rules, it’s important to start with a clean and secure foundation.
These steps prepare your Ubuntu 24.04 LTS server for the UFW+ setup, ensuring consistency, proper logging, and a safe configuration environment.
Update and upgrade your server
Always begin by bringing your server up to date.
This ensures you have the latest kernel, security patches, and package versions:
sudo apt update && sudo apt dist-upgrade -y
If the server reports that a reboot is required to apply a new kernel, you can reboot later after completing all preparation steps.
Create a non-root user
As a good security practice, avoid working directly as root.
Instead, create a new user with sudo
privileges and use it for all management tasks:
sudo adduser yourusername
sudo usermod -aG sudo yourusername
Then exit the SSH session and reconnect using your new user.
Set your hostname and timezone
Setting a proper hostname and timezone helps keep your logs consistent and easy to read:
sudo hostnamectl set-hostname yourservername.yourdomain.com
sudo timedatectl set-timezone Your/Timezone
Always set a valid FQDN (Fully Qualified Domain Name) for the hostname.
Enable UFW and protect SSH access
Start by enabling UFW and applying a rate limit to your SSH port:
sudo ufw limit 22/tcp
sudo ufw enable
This allows SSH connections but slows down repeated failed attempts, reducing the chance of brute-force attacks.
Allow HTTP and HTTPS traffic
Next, open your web ports (80 and 443) – these should always be accessible to the public:
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
At this stage, you’re simply allowing web traffic – UFW+ will later analyze and protect these ports.
Install a web server for testing (optional)
If you’re working on a clean test server, install Nginx to generate normal web traffic for testing:
sudo apt install nginx -y
If you’re running this setup on a production server, you can use your existing web server instead.
Install and prepare Fail2ban
Finally, install Fail2ban – it will serve as the adaptive banning layer in your UFW+ setup:
sudo apt install fail2ban -y
Then copy the default configuration files to their editable .local
versions:
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
sudo cp /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.local
Editing .local
files ensures that your custom settings won’t be overwritten during server or package updates.
Reboot your server
Once everything is done, reboot the server:
sudo reboot
This ensures that all changes are properly applied and any pending updates or configurations take full effect.
UFW and nftables on Modern Ubuntu
Starting with Ubuntu 22.04 and later (including 24.04), UFW uses nftables under the hood by default – even though it still looks like it’s using iptables.
When you run UFW commands, they’re translated through the iptables-nft
compatibility layer, which means the actual packet filtering happens inside nftables.
You can confirm this by running:
sudo update-alternatives --display iptables
And you’ll see something like:
link currently points to /usr/sbin/iptables-nft
To view the live nftables rules created by UFW, run:
sudo nft list ruleset
In short, UFW on modern Ubuntu already uses nftables by default – UFW+ simply takes advantage of that.
Base Mangle Table Rules (Packet Sanity Filtering)
The first real line of defense in UFW+ starts at the mangle table – before UFW’s normal chains even run.
The mangle table handles packets very early – in the PREROUTING
stage – before they reach normal firewall rules.
These rules act as packet sanity checks, filtering out clearly invalid, spoofed, or malformed packets as early as possible to prevent them from consuming server resources.
Edit the UFW before.rules
file (IPv4)
Open the file using your preferred text editor:
sudo vim /etc/ufw/before.rules
I use vim
, but you can use nano
, vi
, or anything you like.
Press
i
to enter insert mode and make your edits.When you’re done, press
Esc
, then type :wq
and hit Enter
to save and exit.Scroll to the very bottom of the file (after the last COMMIT
line), and append the following block:
*mangle
:PREROUTING ACCEPT [0:0]
# Drop all packets flagged as INVALID by connection tracking
-A PREROUTING -m conntrack --ctstate INVALID -j DROP
# Drop NEW incoming TCP packets that do not have the SYN flag (possible scans/attacks)
-A PREROUTING -p tcp ! --syn -m conntrack --ctstate NEW -j DROP
# Drop packets with all TCP flags set (Xmas tree scan)
-A PREROUTING -p tcp --tcp-flags ALL ALL -j DROP
# Drop packets with no TCP flags set (NULL scan)
-A PREROUTING -p tcp --tcp-flags ALL NONE -j DROP
# Drop TCP packets with illegal flag combinations
-A PREROUTING -p tcp --tcp-flags SYN,FIN SYN,FIN -j DROP
-A PREROUTING -p tcp --tcp-flags SYN,RST SYN,RST -j DROP
# Drop packets with invalid or spoofed source addresses
-A PREROUTING -s 0.0.0.0/8 -j DROP
-A PREROUTING -s 10.0.0.0/8 -j DROP
-A PREROUTING -s 172.16.0.0/12 -j DROP
-A PREROUTING -s 192.168.0.0/16 -j DROP
-A PREROUTING -s 127.0.0.0/8 ! -i lo -j DROP
# Drop TCP packets with an invalid MSS (Maximum Segment Size)
-A PREROUTING -p tcp -m conntrack --ctstate NEW -m tcpmss ! --mss 536:65535 -j DROP
COMMIT
Save and exit.
Edit the UFW before6.rules
file (IPv6)
Open:
sudo vim /etc/ufw/before6.rules
Scroll to the bottom (again, after the last COMMIT
line), and append this block:
*mangle
:PREROUTING ACCEPT [0:0]
# Drop all packets flagged as INVALID by connection tracking
-A PREROUTING -m conntrack --ctstate INVALID -j DROP
# Drop NEW incoming TCP packets that do not have the SYN flag (possible scans/attacks)
-A PREROUTING -p tcp ! --syn -m conntrack --ctstate NEW -j DROP
# Drop packets with all TCP flags set (Xmas tree scan)
-A PREROUTING -p tcp --tcp-flags ALL ALL -j DROP
# Drop packets with no TCP flags set (NULL scan)
-A PREROUTING -p tcp --tcp-flags ALL NONE -j DROP
# Drop TCP packets with illegal flag combinations
-A PREROUTING -p tcp --tcp-flags SYN,FIN SYN,FIN -j DROP
-A PREROUTING -p tcp --tcp-flags SYN,RST SYN,RST -j DROP
# Drop IPv6 ULA (Unique Local Addresses) and spoofed loopback
-A PREROUTING -s fc00::/7 -j DROP
-A PREROUTING ! -i lo -s ::1/128 -j DROP
# Drop TCP packets with an invalid MSS (Maximum Segment Size)
-A PREROUTING -p tcp -m conntrack --ctstate NEW -m tcpmss ! --mss 1220:65535 -j DROP
COMMIT
Save and exit.
Reload UFW
Apply the changes:
sudo ufw reload
This reloads UFW and applies the new mangle table rules immediately – there’s no need to reboot or restart any services.
Verify the new rules
To confirm that your new mangle table rules are active:
sudo nft list ruleset | grep mangle -A 20
You should see your PREROUTING
entries under the mangle table.
What these rules do
Each rule in the mangle table serves a specific purpose.
Together, they form a packet sanity layer that keeps invalid or malicious traffic from even entering your firewall.
Drop INVALID
packets
These are packets flagged by the Linux connection tracker as inconsistent – for example:
- A reply to a connection that doesn’t exist,
- Fragments missing from a previous packet, or
- Packets with mismatched headers.
They’re useless for normal communication and often appear during floods or malformed traffic bursts.
Drop NEW
TCP packets without the SYN flag
A valid TCP connection always starts with a SYN packet.
If a packet claims to start a new connection (NEW
state) but has no SYN flag, it’s almost certainly part of a scan or spoofed attempt.
Block Xmas and NULL scans
Attackers sometimes send packets with:
- All flags set (Xmas tree scan), or
- No flags at all (NULL scan).
These are classic reconnaissance techniques – often used by tools like Nmap – to map open ports or trigger unexpected responses.
They’re never part of normal TCP traffic.
Drop illegal TCP flag combinations
Packets that combine SYN+FIN or SYN+RST flags violate the TCP standard – a connection can’t be both starting and finishing (or resetting) at once.
They often indicate broken or malicious network stacks.
Drop spoofed source IP addresses
These rules block packets pretending to come from:
- Private networks (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Reserved addresses (0.0.0.0/8)
- Localhost spoofing (127.0.0.0/8 arriving on a non-loopback interface)
Traffic from these ranges should never appear on a public WAN interface.
If it does, it’s almost always a spoofed packet – someone faking source IPs to hide their identity or poison routing tables.
Drop packets with invalid MSS values
This rule checks the Maximum Segment Size (MSS) in new TCP handshakes and drops any packet outside the legal range:
- 536 bytes – minimum allowed for IPv4 (RFC 879)
- 1220 bytes – minimum derived for IPv6 (based on RFC 8200's 1280 Bytes MTU)
- 65535 bytes – theoretical maximum
If a SYN packet advertises a value smaller or larger than these limits, it’s likely malformed or intentionally crafted.
Whitelisting Cloudflare IP Ranges
If your website is behind Cloudflare, most incoming connections to your server actually come from Cloudflare’s edge proxy IPs, not directly from your visitors.
To avoid rate-limiting or blocking Cloudflare itself, you need to whitelist their IP ranges before your other firewall chains (like connection or SYN flood limits) are evaluated.
This ensures that all legitimate traffic passed through Cloudflare reaches your server without restriction.
Edit the UFW before.rules
file (IPv4)
Open the file:
sudo vim /etc/ufw/before.rules
Scroll until just before UFW’s final # don't delete the 'COMMIT' line
in the *filter
section, and insert the following block:
:CF-EDGE - [0:0]
# Send only web traffic to the CF-EDGE chain
-A ufw-before-input -p tcp --dport 80 -j CF-EDGE
-A ufw-before-input -p tcp --dport 443 -j CF-EDGE
# Allow from Cloudflare edge networks
-A CF-EDGE -s 173.245.48.0/20 -j ACCEPT
-A CF-EDGE -s 103.21.244.0/22 -j ACCEPT
-A CF-EDGE -s 103.22.200.0/22 -j ACCEPT
-A CF-EDGE -s 103.31.4.0/22 -j ACCEPT
-A CF-EDGE -s 141.101.64.0/18 -j ACCEPT
-A CF-EDGE -s 108.162.192.0/18 -j ACCEPT
-A CF-EDGE -s 190.93.240.0/20 -j ACCEPT
-A CF-EDGE -s 188.114.96.0/20 -j ACCEPT
-A CF-EDGE -s 197.234.240.0/22 -j ACCEPT
-A CF-EDGE -s 198.41.128.0/17 -j ACCEPT
-A CF-EDGE -s 162.158.0.0/15 -j ACCEPT
-A CF-EDGE -s 104.16.0.0/13 -j ACCEPT
-A CF-EDGE -s 104.24.0.0/14 -j ACCEPT
-A CF-EDGE -s 172.64.0.0/13 -j ACCEPT
-A CF-EDGE -s 131.0.72.0/22 -j ACCEPT
# Anything else continues through normal UFW processing
-A CF-EDGE -j RETURN
Then leave UFW’s own COMMIT
line in place, and below it you’ll still have your *mangle
section and its own COMMIT
– keep both.
These rules redirect web traffic (ports 80 and 443) from the main ufw-before-input
chain into our custom CF-EDGE
chain. That allows UFW to quickly check if a packet comes from one of Cloudflare’s edge IPs.
If it does, it’s accepted immediately. If not, it continues through the normal UFW rule flow.
Edit the UFW before6.rules
file (IPv6)
Open the file:
sudo vim /etc/ufw/before6.rules
Add this block before the final COMMIT
in the *filter
section:
:CF6-EDGE - [0:0]
# Send only web traffic to the CF6-EDGE chain
-A ufw6-before-input -p tcp --dport 80 -j CF6-EDGE
-A ufw6-before-input -p tcp --dport 443 -j CF6-EDGE
# Allow from Cloudflare edge networks
-A CF6-EDGE -s 2400:cb00::/32 -j ACCEPT
-A CF6-EDGE -s 2606:4700::/32 -j ACCEPT
-A CF6-EDGE -s 2803:f800::/32 -j ACCEPT
-A CF6-EDGE -s 2405:b500::/32 -j ACCEPT
-A CF6-EDGE -s 2405:8100::/32 -j ACCEPT
-A CF6-EDGE -s 2a06:98c0::/29 -j ACCEPT
-A CF6-EDGE -s 2c0f:f248::/32 -j ACCEPT
# Anything else continues through normal UFW processing
-A CF6-EDGE -j RETURN
These rules do the same as the IPv4 ones – they allow Cloudflare’s IPv6 edge IPs to pass through without restriction.
Reload UFW
Apply the changes:
sudo ufw reload
This applies your updated rules immediately.
Cloudflare’s IP ranges sometimes change. You can always find the latest list here: https://www.cloudflare.com/ips/
Consider writing a small script or cron job that checks this list regularly and updates your rules automatically when Cloudflare adds or removes IP ranges.
Connection Limits & SYN Flood Protection
After filtering packets and whitelisting Cloudflare, it’s time to add the connection and rate control rules.
These will become the core of your firewall – keeping traffic balanced and preventing abusive patterns from overwhelming the server.
Edit the UFW before.rules
file (IPv4)
Open the file:
sudo vim /etc/ufw/before.rules
Then add these blocks after your Cloudflare rules and before UFW’s final COMMIT
line:
:CONNLIMIT - [0:0]
# Send only new SYN packets for web ports to the CONNLIMIT chain
-A ufw-before-input -p tcp --syn --dport 80 -j CONNLIMIT
-A ufw-before-input -p tcp --syn --dport 443 -j CONNLIMIT
# If a single IP opens more than 100 concurrent connections:
# - Log the event (limited to once per minute to avoid log spam)
# - Drop further connections
-A CONNLIMIT -m conntrack --ctstate NEW -m connlimit --connlimit-saddr --connlimit-above 100 --connlimit-mask 32 -m limit --limit 1/min --limit-burst 1 -j LOG --log-prefix "[UFW Connlimit] "
-A CONNLIMIT -m conntrack --ctstate NEW -m connlimit --connlimit-saddr --connlimit-above 100 --connlimit-mask 32 -j DROP
# Below the limit → continue to SYN_FLOOD_PROTECTION
-A CONNLIMIT -j RETURN
:SYN_FLOOD_PROTECTION - [0:0]
# Send only new SYN packets for web ports to the SYN_FLOOD_PROTECTION chain
-A ufw-before-input -p tcp --syn --dport 80 -j SYN_FLOOD_PROTECTION
-A ufw-before-input -p tcp --syn --dport 443 -j SYN_FLOOD_PROTECTION
# Normal traffic (≤30 new connections/sec, 60 burst) → allow immediately
-A SYN_FLOOD_PROTECTION -m conntrack --ctstate NEW -m hashlimit --hashlimit-name synlimit4 --hashlimit-upto 30/second --hashlimit-burst 60 --hashlimit-mode srcip --hashlimit-srcmask 32 -j RETURN
# If rate exceeded → log once per minute, then drop
-A SYN_FLOOD_PROTECTION -m conntrack --ctstate NEW -m hashlimit --hashlimit-name synlimit4 --hashlimit-above 30/second --hashlimit-burst 60 --hashlimit-mode srcip --hashlimit-srcmask 32 -m limit --limit 1/minute --limit-burst 1 -j LOG --log-prefix "[UFW SYN Flood Detected] "
-A SYN_FLOOD_PROTECTION -m conntrack --ctstate NEW -m hashlimit --hashlimit-name synlimit4 --hashlimit-above 30/second --hashlimit-burst 60 --hashlimit-mode srcip --hashlimit-srcmask 32 -j DROP
# Return to normal UFW processing
-A SYN_FLOOD_PROTECTION -j RETURN
Let’s quickly break down what’s actually happening behind these rules.
They use a few important firewall modules that make rate control possible:
connlimit
– counts how many concurrent connections each IP address has open.hashlimit
– tracks packet rates per IP address. It limits how many new TCP handshakes an IP can start each second.limit
– rate-limits how often a rule can log messages. This keeps your logs clean and prevents a flood of entries during an attack.
Together, these modules make the firewall adaptive – it reacts to each IP individually instead of applying one static limit to everyone.
Edit the UFW before6.rules
file (IPv6)
Open the file:
sudo vim /etc/ufw/before6.rules
Add the equivalent IPv6 version:
:CONNLIMIT - [0:0]
# Route new SYN packets on web ports to the CONNLIMIT chain
-A ufw6-before-input -p tcp --syn --dport 80 -j CONNLIMIT
-A ufw6-before-input -p tcp --syn --dport 443 -j CONNLIMIT
# If a single IPv6 address opens more than 100 concurrent connections:
# - Log once per minute
# - Drop further connections
-A CONNLIMIT -m conntrack --ctstate NEW -m connlimit --connlimit-saddr --connlimit-above 100 --connlimit-mask 128 -m limit --limit 1/min --limit-burst 1 -j LOG --log-prefix "[UFW Connlimit] "
-A CONNLIMIT -m conntrack --ctstate NEW -m connlimit --connlimit-saddr --connlimit-above 100 --connlimit-mask 128 -j DROP
# Below the limit → continue to SYN_FLOOD_PROTECTION
-A CONNLIMIT -j RETURN
:SYN_FLOOD_PROTECTION - [0:0]
# Route new SYN packets on web ports to the SYN_FLOOD_PROTECTION chain
-A ufw6-before-input -p tcp --syn --dport 80 -j SYN_FLOOD_PROTECTION
-A ufw6-before-input -p tcp --syn --dport 443 -j SYN_FLOOD_PROTECTION
# Normal traffic (≤30 new connections/sec, 60 burst) → allow immediately
-A SYN_FLOOD_PROTECTION -m conntrack --ctstate NEW -m hashlimit --hashlimit-name synlimit6 --hashlimit-upto 30/second --hashlimit-burst 60 --hashlimit-mode srcip --hashlimit-srcmask 128 -j RETURN
# Over the rate limit → log once per minute, then drop
-A SYN_FLOOD_PROTECTION -m conntrack --ctstate NEW -m hashlimit --hashlimit-name synlimit6 --hashlimit-above 30/second --hashlimit-burst 60 --hashlimit-mode srcip --hashlimit-srcmask 128 -m limit --limit 1/minute --limit-burst 1 -j LOG --log-prefix "[UFW SYN Flood Detected] "
-A SYN_FLOOD_PROTECTION -m conntrack --ctstate NEW -m hashlimit --hashlimit-name synlimit6 --hashlimit-above 30/second --hashlimit-burst 60 --hashlimit-mode srcip --hashlimit-srcmask 128 -j DROP
# Return to normal UFW processing
-A SYN_FLOOD_PROTECTION -j RETURN
The IPv6 rules work exactly the same way, using the same modules and logic – the only difference is the address mask:
- IPv4 uses
/32
(one full address). - IPv6 uses
/128
to match a single host, since IPv6 addresses are much longer.
Other than that, both rule sets behave identically: each monitors connections and SYN rates per IP, logs suspicious activity once per minute, and drops abusive traffic early.
Reload UFW
Apply the changes:
sudo ufw reload
This applies your updated rules immediately.
How it works
The two custom chains – CONNLIMIT
and SYN_FLOOD_PROTECTION
– each handle a different kind of abuse pattern:
CONNLIMIT
:
Prevents slow connection floods – when an attacker opens many concurrent connections and leaves them idle to consume memory and sockets.
If a single IP holds more than 100 active connections at once, it’s logged (once per minute) and then dropped. All other IPs pass through normally.SYN_FLOOD_PROTECTION
:
Prevents fast SYN floods – large bursts of new TCP handshake attempts designed to overwhelm the kernel’s connection table.
Allows up to 30 new SYN handshakes per second (with a burst buffer of 60). Anything above that is logged once per minute and dropped.
The two chains complement each other by handling different time scales of abuse:
CONNLIMIT
stops long-lived, slow-drip floods (hundreds of half-open or idle sessions).SYN_FLOOD_PROTECTION
stops short, intense bursts of new handshakes.
Both chains work quietly in the background.
Tuning the limits
You can safely adjust these numbers depending on your traffic patterns:
- Increase
--connlimit-above 100
if your app legitimately uses many concurrent connections (for example, websockets or APIs). - Adjust
--hashlimit-upto 30/second
and--hashlimit-burst 60
if your users trigger false positives under heavy load (for example, during peak visits).
Start conservative and monitor logs to see what works best for your environment.
Fail2ban Integration with nftables
With our UFW rules in place, it’s time to make the setup adaptive – this is where Fail2ban comes in.
Fail2ban watches for repeated bad behavior in your logs and automatically blocks those IPs using nftables sets.
Think of it as the memory layer of your firewall – it notices patterns and reacts.
Disable the Default SSH Jail (Optional)
On Ubuntu, Fail2ban comes with the SSH jail enabled out of the box.
You can confirm this by running:
sudo fail2ban-client status
You’ll see the sshd
jail already running. That’s controlled by this file:
/etc/fail2ban/jail.d/defaults-debian.conf
It includes:
[sshd]
enabled = true
Personally, I don’t need this one – because SSH is already limited by UFW (sudo ufw limit 22/tcp
), and I only use SSH keys with root login and password authentication disabled.
If you’re in the same situation (which is recommended), you can safely disable the jail. Just don’t edit the default file – create a small override instead:
sudo vim /etc/fail2ban/jail.d/override.local
Add:
[sshd]
enabled = false
That’s cleaner and future-proof – your settings won’t be overwritten by package updates.
Create Filters for UFW Log Events
Next, we’ll teach Fail2ban how to recognize our custom UFW log messages.
We’ll create two filters: one for SYN flood logs and one for connection limit logs.
Go to the /etc/fail2ban/filter.d/
directory and create two new filter files:
cd /etc/fail2ban/filter.d
sudo touch synflood.conf connlimit.conf
Open synflood.conf
and add:
[Definition]
failregex = .*UFW SYN Flood Detected.*SRC=<HOST>.*DPT=\d+.*
ignoreregex =
Then open connlimit.conf
and add:
[Definition]
failregex = .*UFW Connlimit.*SRC=<HOST>.*DPT=\d+.*
ignoreregex =
That’s it.
Now whenever UFW logs a [UFW SYN Flood Detected]
or [UFW Connlimit]
message, Fail2ban will now recognize it instantly and take action.
Extend Database Retention
By default, Fail2ban forgets everything after a day. That’s not ideal for catching repeat offenders
For long-term tracking (especially for the recidive jail), we extend Fail2ban’s database retention period to 30 days.
Open the /etc/fail2ban/fail2ban.local
file and update:
dbpurgeage = 30d
This makes sure Fail2ban remembers old bans, which is essential for the recidive jail we’ll set up next.
Create the Jails
Now for the main part – the jails that actually watch our logs and block bad IPs.
Go to the /etc/fail2ban/jail.d/
directory and create the files:
cd /etc/fail2ban/jail.d
sudo touch synflood.local connlimit.local recidive.local
Open synflood.local
and add:
[synflood]
enabled = true
backend = systemd
journalmatch = _TRANSPORT=kernel
usedns = no
filter = synflood
banaction = nftables[type=allports, blocktype=drop]
maxretry = 3
findtime = 5m
bantime = 5m
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1h
bantime.rndtime = 1m
Then open connlimit.local
and add:
[connlimit]
enabled = true
backend = systemd
journalmatch = _TRANSPORT=kernel
usedns = no
filter = connlimit
banaction = nftables[type=allports, blocktype=drop]
maxretry = 3
findtime = 5m
bantime = 5m
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 1h
bantime.rndtime = 1m
Finally, open recidive.local
and add:
[recidive]
enabled = true
usedns = no
filter = recidive
banaction = nftables[type=allports, blocktype=drop]
logpath = /var/log/fail2ban.log
maxretry = 3
findtime = 1d
bantime = 7d
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 21d
bantime.rndtime = 1m
These three jails work together to handle short-term attacks and long-term offenders.
Each listens for the UFW log messages we defined earlier and bans abusive IPs automatically using nftables sets – fast, clean, and scalable even with hundreds (or thousands) of bans.
How the Jails Work
Now that the jails are in place, here’s what’s actually happening behind the scenes.
Each jail watches for specific log messages coming from UFW.
When one of your firewall rules logs an event – like [UFW SYN Flood Detected]
or [UFW Connlimit]
– Fail2ban picks it up, checks how often it happens for that IP, and decides what to do next.
If the same IP keeps showing up too frequently within a certain time window (findtime
), Fail2ban considers it abusive and bans it for the amount of time defined by bantime
.
Why these settings:
backend = systemd
+journalmatch = _TRANSPORT=kernel
– tells Fail2ban to read the systemd journal directly instead of scanning text logs. This is faster and more reliable on modern Ubuntu servers.usedns = no
– keeps things simple and fast. It avoids unnecessary DNS lookups when banning IPs.banaction = nftables[type=allports, blocktype=drop]
– uses native nftables sets to drop all traffic from the banned IPs, on every port.maxretry = 3
– after three strikes (withinfindtime
), the IP gets banned.findtime = 5m
(or1d
for recidive) – defines how long Fail2ban looks back to count those strikes. For example, if an IP triggers the rule three times in five minutes, it’s banned.bantime = 5m
– first-time bans are short – just five minutes. This helps avoid false positives.bantime.increment = true
+bantime.factor = 2
– makes bans smarter. If an IP keeps getting caught again, its ban time doubles each time (up tobantime.maxtime
).bantime.rndtime = 1m
– adds a small random offset to avoid synchronized unbans (where many IPs get released at once).
The recidive
jail functions as long-term memory – monitoring Fail2ban’s own logs to identify IPs that have been banned multiple times across days or weeks.
Apply the Changes
Once everything’s ready, restart Fail2ban:
sudo systemctl restart fail2ban
sudo fail2ban-client status
You should now see your three new jails (synflood
, connlimit
, and recidive
) active and running.
Why I Use DROP
(silent) instead of REJECT
/ TCP RST
I decided early on that I wanted this setup to be quiet. No messages, no hints, no you’re blocked replies – just silence.
That’s why every rule in UFW+ uses DROP
, not REJECT
or REJECT with tcp-reset
.
When you DROP
a packet, it just disappears. The sender gets no answer at all.
When you REJECT
it, you actually send something back – an error or reset – which tells whoever’s on the other side that you’re alive and blocking them.
For normal users, that kind of feedback is nice and clean. But for attackers, it’s valuable information.
A fast connection refused or port unreachable tells their scanner exactly what’s happening – and that’s the opposite of what I want.
With DROP
, everything goes dark. Scanners hang, tools time out, and automated floods get slower because they have no signal to adjust their timing or rate.
Sure, it’s not perfect – some legitimate tools might just sit there waiting until they time out – but for a public-facing web server, that tradeoff is totally worth it.
So, I use DROP
because I want the firewall to act like a black hole. If someone’s attacking my server, I don’t want them to know they hit a wall – I want them to wonder if they ever even reached me.
How It All Flows Together
Now that everything’s in place – the mangle table filters, Cloudflare whitelist, firewall rules, and Fail2ban jails – it’s worth seeing how the whole setup actually behaves.
Think of this as a quick packet journey through UFW+.
Normal Visitor (Direct Access)
- Mangle (
PREROUTING
) drops any junk or malformed packets. - Passes to Fail2ban (no ban, clean IP).
- Reaches
CF-EDGE
→ returns normally. - Hits
CONNLIMIT
→ under 100 connections, returns. - Hits
SYN_FLOOD_PROTECTION
→ under 30 SYNs/sec, returns. - Finally, it reaches your web server – fast and clean.
Cloudflare Traffic
- Same initial mangle and Fail2ban checks.
- Hits
CF-EDGE
and gets accepted immediately. It skips theCONNLIMIT
andSYN_FLOOD_PROTECTION
limit stages entirely (by design).
Slow Connection Hoarder
- Opens hundreds of idle sockets – say, 150 at once.
- Passes the SYN rate limit (only 5 SYNs/sec).
- Hits
CONNLIMIT
→ logs the event, drops anything over 100 connections. That IP starts failing silently after its 100th connection. - After repeated detections, Fail2ban steps in, adds the IP to the nftables ban set, and future packets die immediately.
SYN Flood (Fast Burst)
- Sends a wave – say 200 SYN packets per second.
- Trips the
SYN_FLOOD_PROTECTION
hashlimit. - Logs once per minute and drops the rest (silent).
- After repeated detections, Fail2ban steps in, adds the IP to the nftables ban set, and future packets die immediately.
Kernel Hardening (Complementary to UFW+)
Your firewall takes care of the traffic that tries to reach your server – but what about the packets that actually make it through?
You can go one step further by hardening the kernel itself, so that even if bad packets slip through, the server’s networking behavior remains safe and predictable.
- UFW+ filters the noise: it drops invalid, spoofed, or suspicious packets before they ever touch your services.
- Kernel hardening (via
sysctl
) tells the kernel how to behave – what to ignore, what to drop, and how to react when things look weird at the protocol level.
Together, they make a perfect pair: your firewall cleans the input, and your kernel stays disciplined about what it accepts.
Adding Kernel Hardening Settings
UFW's /etc/ufw/sysctl.conf
already includes a few sensible defaults – for example, it disables ICMP redirects and ignores bogus ICMP errors.
You’ll simply add extra security parameters below them to enhance protection further.
Open the file:
sudo vim /etc/ufw/sysctl.conf
Append these settings at the end:
# Enable SYN cookies (mitigates SYN floods at TCP level)
net.ipv4.tcp_syncookies = 1
# Drop invalid packets at routing level
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Don’t accept source-routed packets
net.ipv4.conf.all.accept_source_route = 0
net.ipv6.conf.all.accept_source_route = 0
# Protect against TIME-WAIT assassination
net.ipv4.tcp_rfc1337 = 1
Save and apply the changes:
sudo ufw reload
That’s it.
UFW automatically loads its own sysctl file on startup, so these rules will take effect right away – no separate sysctl -p
needed.
Keeping your hardening settings here also keeps everything tidy – firewall logic and kernel behavior in one place, under UFW’s control.
For a full breakdown of Linux kernel hardening – including detailed explanations and additional
sysctl
options – check out my complete guide: Kernel Hardening: Securing Your Linux ServerTesting and Verification
I test UFW+ with two Hetzner VMs: one runs the UFW+ setup (the victim) and the other is my attacker box with apache-utils
installed so I can use the ab
command.
Below I show the exact commands I ran, the logs I saw, and what each result means.
Quick SYN-Flood Smoke Test
From the attacker I run this command, which opens a lot of new connections quickly (no keep-alive):
sudo ab -n 500 -c 20 http://victim-ipv4/
I watch the logs on the victim in real time:
sudo tail -f /var/log/syslog | grep -E 'UFW Connlimit|UFW SYN Flood Detected'
What happened to me:
- The
SYN_FLOOD_PROTECTION
chain started logging[UFW SYN Flood Detected]
messages. - After three log entries inside five minutes, Fail2ban banned the attacker IP and added it to an nftables set.
I verified the ban like this:
sudo fail2ban-client status synflood
sudo nft list table inet f2b-table
You should see the attacker’s IP in the addr-set-synflood
(or similar) set.
The reason this triggered is that ab -n 500 -c 20
(without -k
) opens one new TCP connection per request. On a fast link, that translated to thousands of SYNs/sec, which trips a 30 SYN/s hashlimit very quickly.
The Keep-alive Difference (Important)
I reran the same test but with keep-alive enabled:
sudo ab -k -n 500 -c 20 http://victim-ipv4/
This test did not trigger blocking.
With -k
, ab
reuses the 20 connections (you’ll see Keep-Alive requests: 500
in the output), so only about 20 SYNs are sent rather than 500.
Browsers reuse connections the same way, so this mode is a better approximation of real-world traffic.
Testing CONNLIMIT
(concurrent connections)
To hit CONNLIMIT
you must keep many simultaneous connections open.
My first attempt was:
sudo ab -k -n 2000 -c 101 http://victim-ipv4/
That didn’t hit CONNLIMIT
because SYN_FLOOD_PROTECTION
blocked the test first — opening 101 connections quickly trips the SYN limit rules.
To test CONNLIMIT
properly I temporarily bypassed SYN_FLOOD_PROTECTION
for my attacker IP and ran the keep-alive test again:
sudo iptables -I SYN_FLOOD_PROTECTION 1 -s attacker-ipv4 -p tcp -m multiport --dports 80,443 -j ACCEPT
sudo ab -k -n 2000 -c 101 http://victim-ipv4/
With that bypass in place, CONNLIMIT
logged hits and began dropping connections above 100, which confirmed the connection-cap behavior works as designed.
At the end, I used the sudo ufw reload
command to remove the bypass.
IPv4 vs IPv6 behavior
Bans are per address family.
When the attacker IPv4 address was banned, the victim’s IPv6 address still responded normally.
For example, after banning IPv4 I got the default Nginx page when curling the victim’s IPv6:
curl http://[victim-ipv6]/
# -> returns the Nginx index.html content
But curling the victim’s IPv4 hung with no response:
curl http:/victim-ipv4/
# -> hangs / no output
If your web server serves both families, you can test both.
Conclusion and Final Thoughts
If you’ve followed everything up to this point – congratulations.
You’ve built a layered firewall that’s far more capable than a stock UFW setup.
Your server now:
- Filters malformed or spoofed packets right at the
PREROUTING
stage. - Whitelists Cloudflare edge IPs for clean traffic prioritization.
- Enforces per-IP connection and SYN limits that adapt to activity, not static thresholds.
- Reacts to repeated abuse through Fail2ban, which adds offenders to nftables sets instantly.
- Escalates bans for repeat offenders (short initial bans that grow with repeat offenses via the recidive logic).
- Adds an extra layer of resilience with kernel hardening, so even low-level packet tricks don’t slip through.
All of these layers work together silently.
In testing, the difference is immediate. You can hammer the server with raw SYN floods, or try slow connection hoarding, and still see the web server stay responsive.
At the same time, real visitors and search bots browse normally without ever noticing what’s happening behind the scenes.
I’ll continue extending UFW+ to cover more ports and add UDP/QUIC protections (port 443/UDP) in future versions.
When those updates roll out, I’ll update this guide – and if you want to be notified when I publish new versions, you can subscribe to the newsletter.
Thanks for following along – and if you’ve built your own variation of this setup or tested it in your own environment, I’d love to hear how it performed.
If you run into any issues or need further help, feel free to revisit this guide or reach out for assistance.
And if you found this useful, or have thoughts and feedback, drop them in the discussion section – I always enjoy seeing how others build on this.
Until then, keep your packets clean and your logs quiet.
Discussion