Home · About · Services · Industries · Projects · Products · Process · Blog · FAQ · Contact
UFW and Fail2ban on Ubuntu: a setup that won't lock you out How we set up UFW and Fail2ban on Ubuntu servers: safe rule order, the Docker port caveat, a jail.local that works, and how to prove bans are happening.
A new Ubuntu VPS is online for about ten minutes before the first SSH login attempts arrive from addresses you have never heard of. Most are harmless guesses, but they fill logs and occasionally find a weak password. Two tools in Ubuntu's own repositories deal with most of this: UFW and Fail2ban.
The short answer for a UFW and Fail2ban setup on Ubuntu: allow SSH in UFW first, set incoming to deny and outgoing to allow, open only the ports your services need, then enable it. Install Fail2ban, put settings in jail.local, enable the sshd jail with your own IP in ignoreip, and confirm it matches real failed logins.
Below is the setup we use on Ubuntu 24.04 LTS, including the parts that go wrong: lockouts, Docker bypassing the firewall, and a jail that runs while banning nothing.
What each tool does, and what it does not UFW (Uncomplicated Firewall) is a front end for the kernel's packet filter. On current Ubuntu releases it writes rules through iptables-nft, so they end up in nftables. It decides which ports are reachable and from where, not what happens on them.
Fail2ban watches logs or the systemd journal for repeated failures, such as wrong SSH passwords, and temporarily bans the offending address. It reacts to behaviour on ports you have chosen to leave open.
Neither one replaces key-only SSH, patching or keeping databases off the public internet. UFW is the outer wall; Fail2ban cuts noise and slows guessing on the doors you leave open.
Step 1: set up UFW without locking yourself out UFW is installed by default on Ubuntu Server but disabled. Order matters: add the SSH rule before you enable the firewall, and keep your current SSH session open until you have tested a second login.
sudo apt update && sudo apt install ufw
# Defaults: nothing in, everything out
sudo ufw default deny incoming
sudo ufw default allow outgoing
# SSH first. limit = deny an IP after 6 connections in 30 seconds
sudo ufw limit 22/tcp
# Web traffic, if this server runs NGINX or similar
sudo ufw allow 80,443/tcp
# Check what will be applied, then turn it on
sudo ufw show added
sudo ufw enable
sudo ufw status verboseIf SSH listens on another port, allow that port instead of 22 before enabling. On Ubuntu 24.04, sshd is socket-activated, so a port change also needs sudo systemctl daemon-reload and sudo systemctl restart ssh.socket. On a cloud VM, know how to reach the provider's web console first; it is the way back in if a rule is wrong.
Some details worth knowing:
IPv6 rules are created automatically as long as IPV6=yes in /etc/default/ufw, which is the default. With IPV6=no, a server with a public IPv6 address is not filtered on IPv6 at all. Rules are evaluated in order, first match wins. Use sudo ufw status numbered to see the order and sudo ufw delete 4 to remove a rule by number. Restrict admin and database ports by source rather than opening them: sudo ufw allow from 203.0.113.10 to any port 5432 proto tcp. Add a comment to each rule (sudo ufw allow 443/tcp comment 'nginx') so the next person knows why it exists. sudo ufw logging low records blocked packets. On Ubuntu Server they go to /var/log/ufw.log; journalctl -k | grep UFW works everywhere. On a campus server we run, MongoDB and PostgreSQL accept connections only from approved IP addresses. We also keep the host firewall consistent with the cloud provider's firewall, so if one is loosened by mistake the other still says no.
One caveat if the server runs Docker: a container published with -p 5432:5432 or ports: in a compose file is reachable from the internet even when UFW says that port is denied. Docker writes its own NAT and forwarding rules, and that traffic never passes through the INPUT rules UFW manages.
The fixes, in order of preference:
Do not publish ports you do not need. Containers on the same Docker network can reach each other without published ports. Bind published ports to localhost, for example 127.0.0.1:5432:5432, and put a reverse proxy or an SSH tunnel in front. If a port must be public but restricted, add rules to the DOCKER-USER chain, which Docker evaluates before its own forwarding rules. Match on the container's port, since the destination has already been translated by then. Check from outside the server, not from the server itself: nmap -Pn your.server.ip from a laptop shows what is actually reachable. Step 2: install Fail2ban and write jail.local Fail2ban's package ships its defaults in /etc/fail2ban/jail.conf, which package upgrades may overwrite. Do not edit it. Put your settings in /etc/fail2ban/jail.local, which overrides it.
sudo apt install fail2ban python3-systemd
sudo nano /etc/fail2ban/jail.local[DEFAULT]
# Never ban yourself: localhost plus your office or VPN address
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
bantime = 1h
findtime = 10m
maxretry = 5
# Repeat offenders get longer bans, capped at a week
bantime.increment = true
bantime.maxtime = 1w
# Ban with nftables; coexists with UFW
banaction = nftables-multiport
banaction_allports = nftables-allports
[sshd]
enabled = true
backend = systemd
port = ssh
maxretry = 4ignoreip is the most important line: an office behind one NAT address can get itself banned by a single person mistyping a password. bantime.increment roughly doubles the ban each time the same address returns, which is simpler than running a separate recidive jail. The nftables actions keep Fail2ban's bans in their own table, separate from the rules UFW manages. banaction = ufw also exists if you want bans visible in ufw status, but it scales worse with long ban lists.
backend = systemd reads the journal directly, which works whether or not rsyslog is writing /var/log/auth.log. If SSH is not on 22, set port to the real number, otherwise the ban blocks the wrong port.
sudo fail2ban-client -t # test the configuration
sudo systemctl enable fail2ban
sudo systemctl restart fail2ban
sudo fail2ban-client status sshdStep 3: prove the jail is catching anything The most common Fail2ban failure is silent: the jail is listed, but Currently failed stays at zero for weeks. That usually means the filter does not match the log lines your OpenSSH version writes, or the jail is reading the wrong source. Test the filter against real data:
# Run the sshd filter against the journal
sudo fail2ban-regex systemd-journal sshd
# Or against the log file, if your system writes one
sudo fail2ban-regex /var/log/auth.log sshdLook at the Failregex line in the output. On an internet-facing server it should show matches within a day. If it shows none while the journal clearly contains failed logins, check that your fail2ban package is current. OpenSSH 9.8 moved per-connection logging to a separate sshd-session process, and older sshd filters miss those journal entries.
Then try it for real from a machine that is not in ignoreip, such as a phone hotspot: fail a few logins and watch the jail.
sudo fail2ban-client status sshd
sudo fail2ban-client set sshd unbanip 198.51.100.7
sudo nft list table inet f2b-tableProtecting web services as well For NGINX, the useful bundled filters are nginx-http-auth for basic-auth failures, nginx-limit-req for clients that keep hitting a limit_req rate limit, and nginx-botsearch for scanners probing for paths that do not exist. Each needs the matching log file and, for limit-req, a rate limit already configured in NGINX.
[nginx-limit-req]
enabled = true
port = http,https
logpath = /var/log/nginx/error.log
maxretry = 10
[nginx-botsearch]
enabled = true
port = http,https
logpath = /var/log/nginx/error.logBe careful if the site sits behind Cloudflare, another proxy or a load balancer. Connections arrive from the proxy, so a host-level ban either does nothing or blocks the proxy and takes the site down. In that set-up, do the blocking at the proxy, and restrict ports 80 and 443 in UFW to the proxy's published address ranges instead.
For application logins, you can write a filter if the app logs failures with the client address, but rate limiting and lockouts inside the application usually work better.
Close the door Fail2ban is guarding Fail2ban slows password guessing. Turning off password login for SSH stops it. On Ubuntu, cloud images often include /etc/ssh/sshd_config.d/50-cloud-init.conf, which can set PasswordAuthentication yes. sshd uses the first value it reads for each setting and includes these files in name order, so a file with a lower number wins.
# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
# Validate, then reload, keeping the current session open
sudo sshd -t && sudo systemctl reload sshConfirm a fresh key-based login works from a second terminal before you close the first one.
A checklist before you log out SSH is allowed (or limited) in UFW on the port it actually listens on. Default incoming policy is deny, and only the ports you can name a reason for are open. Database and admin ports are restricted by source address, not open to everyone. No Docker container publishes a port to 0.0.0.0 that should be private; checked with nmap from outside. jail.local exists, jail.conf is untouched, and your own addresses are in ignoreip. fail2ban-regex shows the sshd filter matching real failures. Password authentication for SSH is off, verified with sudo sshd -T | grep -i passwordauthentication. Someone knows how to reach the provider console if a rule ever locks everyone out. If a server has run for a while without these basics, or nobody is sure which ports are open, run the checklist first. It usually shows where the gaps are, and an outside review is quicker than guessing.
Infigini IT Services · 11 Kanwali Road, Dehradun 248001, India · info@infigini.com · +91 95684 87789