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 -50If 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/tcpThis 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 addedufw status verbose# Output includes:# 22/tcp (SSH) LIMIT IN AnywhereUnder the hood, this adds iptables rules using the recent module. You can see the raw rules:
iptables -L ufw-user-limit-accept -n -viptables -L ufw-user-limit -n -vLimitations 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 packetsufw 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 mediumThis 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 monitoringtail -f /var/log/ufw.log
# Filter by source IPgrep "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 -20The 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=0Key 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 profilesufw app list
# Show details of a profileufw app info "Nginx Full"
# Use a profile instead of port numbersufw allow "Nginx Full"You can create custom profiles:
cat /etc/ufw/applications.d/myapp[MyApp]title=My Applicationdescription=Custom app profileports=8080,8443/tcpThen 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)
# 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 DROPAllowing 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 DROPPort 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 ACCEPTThis 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=yesRules 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:
IPV6=noThen 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 myappOr in docker-compose.yml:
services: myapp: ports: - "127.0.0.1:8080:80" # Only accessible locallyYour nginx reverse proxy on the host can still reach it via localhost, but external traffic can’t.
Fix 2: Disable Docker’s iptables Management
{ "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 portiptables -I DOCKER-USER -p tcp --dport 8080 ! -s 192.168.1.0/24 -j DROPThe ! -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-persistentnetfilter-persistent savefail2ban 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 fail2bancp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localBasic Configuration
[DEFAULT]bantime = 1hfindtime = 10mmaxretry = 5backend = systemd
# Use UFW as the ban actionbanaction = ufw
[sshd]enabled = trueport = sshlogpath = %(sshd_log)smaxretry = 3bantime = 24hThe 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
[nginx-req-limit]enabled = truefilter = nginx-req-limitaction = ufwlogpath = /var/log/nginx/error.logfindtime = 600maxretry = 10bantime = 7200[Definition]failregex = limiting requests, excess:.* by zone.*client: <HOST>ignoreregex =Managing fail2ban Bans
# See current statusfail2ban-client status
# See SSH jail statusfail2ban-client status sshd
# Manually ban an IPfail2ban-client set sshd banip 1.2.3.4
# Unban an IPfail2ban-client set sshd unbanip 1.2.3.4
# See banned IPsfail2ban-client get sshd banlistTesting 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 machinenmap -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 runningufw status verbose
# Check iptables directly (what UFW generates)iptables -L -n --line-numbersUFW Rule Management Best Practices
# Number your rules for easy deletionufw 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 positionufw insert 1 deny from 1.2.3.4A 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.