Your Firewall Log Looks Like a War Zone and You Haven’t Checked It
If you’ve never looked at your UFW logs on an internet-exposed server, open a terminal right now and run:
grep UFW /var/log/ufw.log | tail -50
If the server has been up for more than a week, what you’re looking at is a graveyard of failed intrusion attempts. Your SSH port is getting hammered like it owes someone money. There are port scans from IP ranges spread across six continents. This is not unusual — it’s the background radiation of the internet. Every server gets this.
UFW’s default ufw allow and ufw deny rules are a start, but they’re blunt instruments. This guide covers the sharper tools: rate limiting to throttle brute-force attempts, proper logging configuration, the infamous Docker bypass problem, advanced iptables rules via before.rules, and finally integrating fail2ban to dynamically ban the worst offenders.
Rate Limiting with ufw limit
ufw limit is UFW’s built-in rate limiting. For SSH, instead of ufw allow 22, use:
ufw limit ssh
# or equivalently:
ufw limit 22/tcp
This implements a simple rule: if an IP makes more than 6 connection attempts to that port within 30 seconds, it gets blocked for 30 seconds. It won’t stop a slow brute-force attack, but it handles the automated spray-and-pray variety.
# Verify the rule was added
ufw status verbose
# Output includes:
# 22/tcp (SSH) LIMIT IN Anywhere
Under the hood, this adds iptables rules using the recent module. You can see the raw rules:
iptables -L ufw-user-limit-accept -n -v
iptables -L ufw-user-limit -n -v
Limitations of ufw limit
It’s only a 30-second block. That’s enough to slow automated tools, not enough to stop a determined attacker. For anything beyond basic protection, you need fail2ban (covered below) or something like CrowdSec.
UFW Logging: Actually Making It Useful
UFW has five log levels, and the default is underwhelming:
# Log levels:
ufw logging off # No logging (please don't)
ufw logging low # Blocked packets only (default after enable)
ufw logging medium # Blocked + invalid packets
ufw logging high # All packets (prepare for log rotation to sweat)
ufw logging full # Everything including rate-limited (very verbose)
For a server you care about:
ufw logging medium
This gives you blocked connection attempts without flooding your logs with every allowed packet. You can see what’s being blocked without drowning in noise.
Reading UFW Logs
# Live monitoring
tail -f /var/log/ufw.log
# Filter by source IP
grep "SRC=1.2.3.4" /var/log/ufw.log
# Count attempts by source IP (top offenders)
grep "UFW BLOCK" /var/log/ufw.log | \
grep -oP 'SRC=\K[\d.]+' | \
sort | uniq -c | sort -rn | head -20
The log format:
Apr 1 04:23:11 hostname kernel: [UFW BLOCK] IN=eth0 OUT= MAC=... SRC=185.234.XXX.XXX DST=YOUR.IP.HERE LEN=44 TOS=0x00 PREC=0x00 TTL=242 ID=54321 PROTO=TCP SPT=52480 DPT=22 WINDOW=1024 RES=0x00 SYN URGP=0
Key fields: SRC (attacker IP), DPT (destination port they’re hitting), PROTO (TCP/UDP).
Application Profiles: The Clean Way to Manage Rules
UFW supports application profiles — named rule sets stored in /etc/ufw/applications.d/. Many packages install their own profiles.
# See available profiles
ufw app list
# Show details of a profile
ufw app info 'Nginx Full'
# Use a profile instead of port numbers
ufw allow 'Nginx Full'
You can create custom profiles:
cat /etc/ufw/applications.d/myapp
[MyApp]
title=My Application
description=Custom app profile
ports=8080,8443/tcp
Then ufw allow MyApp. When your app’s ports change, update the profile file and ufw app update MyApp. Much cleaner than remembering which port you opened for what six months ago.
before.rules: Where UFW’s Training Wheels Come Off
UFW processes rules from several files. before.rules runs before your user-defined rules and is where you add iptables rules that UFW’s syntax can’t express.
Location: /etc/ufw/before.rules
Blocking an Entire Country (or Just Annoying IP Ranges)
# /etc/ufw/before.rules
# Add BEFORE the COMMIT line at the end of the file
# Drop traffic from a known-bad CIDR
-A ufw-before-input -s 185.220.0.0/16 -j DROP
# Tor exit nodes (update this list periodically)
-A ufw-before-input -s 198.96.155.0/24 -j DROP
Allowing Established Connections First (Performance)
This is usually already in the default before.rules, but worth knowing:
# Accept established/related connections
-A ufw-before-input -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A ufw-before-input -m conntrack --ctstate INVALID -j DROP
Port Knocking
The paranoid way to hide SSH entirely until a specific sequence of ports is knocked:
# Requires iptables recent module
-N ufw-knock
-A ufw-before-input -p tcp --dport 7000 -m recent --set --name KNOCK1 -j DROP
-A ufw-before-input -p tcp --dport 8000 -m recent --name KNOCK1 --rcheck \
-m recent --set --name KNOCK2 -j DROP
-A ufw-before-input -p tcp --dport 9000 -m recent --name KNOCK2 --rcheck \
-m recent --set --name KNOCK3 -j DROP
-A ufw-before-input -p tcp --dport 22 -m recent --name KNOCK3 --rcheck -j ACCEPT
This requires clients to hit ports 7000, 8000, 9000 in sequence before SSH connections are allowed. Overkill for most, but genuinely useful if you want SSH to be invisible to scanners.
IPv6: Don’t Leave the Back Door Open
UFW manages IPv4 and IPv6 separately. Check that IPv6 is enabled:
grep IPV6 /etc/default/ufw
# Should show: IPV6=yes
Rules you add apply to both automatically when you use service names, but verify:
ufw status verbose
# You should see rules for both "Anywhere" and "Anywhere (v6)"
If you’re not using IPv6 (and many home labs don’t need it), you can disable it entirely:
# /etc/default/ufw
IPV6=no
Then ufw disable && ufw enable to reload.
The Docker UFW Bypass Problem
Here it is — the gotcha that catches everyone who runs Docker on an internet-exposed server.
Docker directly manipulates iptables to set up port forwarding for containers. It adds rules to the DOCKER chain that run before UFW’s rules. The result: when you run docker run -p 8080:80 myapp, port 8080 is accessible from the internet regardless of your UFW rules. UFW’s ufw deny 8080 does nothing.
This is by design (Docker needs to manage networking), but it’s a significant security surprise.
Fix 1: Bind to Localhost
If you’re using a reverse proxy (nginx, Traefik) on the same host, bind Docker ports to localhost:
# Instead of:
docker run -p 8080:80 myapp
# Use:
docker run -p 127.0.0.1:8080:80 myapp
Or in docker-compose.yml:
services:
myapp:
ports:
- "127.0.0.1:8080:80" # Only accessible locally
Your nginx reverse proxy on the host can still reach it via localhost, but external traffic can’t.
Fix 2: Disable Docker’s iptables Management
# /etc/docker/daemon.json
{
"iptables": false
}
Warning: this breaks Docker networking in various ways. You’d need to manage routing manually. Not recommended unless you know what you’re doing.
Fix 3: DOCKER-USER Chain
Docker provides a DOCKER-USER chain for user rules that runs before Docker’s own rules:
# Block external access to a Docker port
iptables -I DOCKER-USER -p tcp --dport 8080 ! -s 192.168.1.0/24 -j DROP
The ! -s 192.168.1.0/24 means “not from my local network” — so local access still works, external is blocked. Make these persistent with iptables-persistent:
apt install iptables-persistent
netfilter-persistent save
fail2ban Integration: Dynamic Banning
fail2ban watches log files for patterns (failed logins, too many requests) and dynamically adds iptables rules to ban offending IPs. Combined with UFW, it’s a practical first line of automated defense.
Installation
apt install fail2ban
cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local
Basic Configuration
# /etc/fail2ban/jail.local
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
# Use UFW as the ban action
banaction = ufw
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
maxretry = 3
bantime = 24h
The banaction = ufw setting tells fail2ban to use ufw insert 1 deny from X.X.X.X instead of raw iptables. This keeps your UFW rules consistent.
Nginx Rate Limiting with fail2ban
# /etc/fail2ban/jail.local
[nginx-req-limit]
enabled = true
filter = nginx-req-limit
action = ufw
logpath = /var/log/nginx/error.log
findtime = 600
maxretry = 10
bantime = 7200
# /etc/fail2ban/filter.d/nginx-req-limit.conf
[Definition]
failregex = limiting requests, excess:.* by zone.*client: <HOST>
ignoreregex =
Managing fail2ban Bans
# See current status
fail2ban-client status
# See SSH jail status
fail2ban-client status sshd
# Manually ban an IP
fail2ban-client set sshd banip 1.2.3.4
# Unban an IP
fail2ban-client set sshd unbanip 1.2.3.4
# See banned IPs
fail2ban-client get sshd banlist
Testing Your Rules
Rule testing is not optional. After any significant firewall change, verify from an external network (your phone on mobile data, a VPS you control, or a friend’s machine) that:
- Your allowed services are reachable
- Your blocked ports are actually blocked
- Rate limiting engages when expected
# Test port accessibility from another machine
nmap -p 22,80,443,8080 YOUR.SERVER.IP
# Test rate limiting (will trigger block after 6 attempts in 30s)
for i in {1..10}; do nc -z YOUR.SERVER.IP 22; done
# Verify UFW is actually running
ufw status verbose
# Check iptables directly (what UFW generates)
iptables -L -n --line-numbers
UFW Rule Management Best Practices
# Number your rules for easy deletion
ufw status numbered
# Delete by number (much easier than repeating the full rule)
ufw delete 5
# Reset everything (nuclear option)
ufw reset
# Load order matters — rules are checked top to bottom
# Insert a rule at a specific position
ufw insert 1 deny from 1.2.3.4
A firewall you understand and maintain beats a complex one you’ve half-forgotten. Start with the basics, add rate limiting for exposed services, fix the Docker bypass if you’re running containers, and let fail2ban handle the persistent offenders. Your SSH logs will still look like a war zone — but now you’re actively defending rather than just watching.